Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

geocatalog

Report 6 · The standalone spatiotemporal index, as scoped and as built

Abstract

A catalog is an index over geospatial files. Each row holds one file’s footprint, time interval, CRS and path, so a question like “which files overlap this area between these dates?” is answered in milliseconds, without opening any file. This report scoped geocatalog as a standalone package extracted from geotoolz. It has been built, and it has outgrown the scope: discovery sources, provenance bundles, matchups and staging sit on top of the index. Most of the scoping decisions held: GeoSlice lives in the catalog, there is no pipekit dependency, and the patcher bridge is a Protocol. The migration back into geotoolz has not happened, and the light base install did not survive. Both are open questions at the end.

Keywords:geocatalogGeoSliceGeoParquetDuckDBcatalog

Where geocatalog sits

The scoping report placed geocatalog beside pipekit and geopatcher, all three above georeader and below geotoolz and xrtoolz (the picture is drawn in Report 7; the stack-wide rules are in Report 16). That still holds: geocatalog owns the spatiotemporal index end to end, has no pipekit reference at all, loads geopatcher and geocloud only lazily inside the functions that need them, and consumes georeader rather than replacing it.

What is in geocatalog

The mental model

From the repo’s own docs. You build a GeoCatalog once, from a directory of files, a STAC search, a NASA CMR call or a hand-made list. You hand it GeoSlices to query, and it hands back smaller catalogs (the matching rows). When you want pixels, a loader takes the slice and the matching catalog and returns a GeoTensor or an xr.Dataset. At every step the geometry, time and CRS are explicit, so there is no hidden state (Figure 1).

geocatalog's data flow from top to bottom. Discovery sources (STACSource, EarthAccessSource, CMRSource, GEESource) and local files feed a CatalogBundle or the builders. Both produce a GeoCatalog, backed by InMemoryGeoCatalog or DuckDBGeoCatalog, which persists to GeoParquet 1.1 and is read back by open_catalog. Querying yields GeoSlices, which load_raster, load_xarray and load_vector turn into GeoTensor or xr.Dataset.

Figure 1:Catalogs are the index; GeoSlice is the unit of work; loaders materialise it. Redrawn from the data-flow diagram in the geocatalog repo’s concepts page.

It works in three layers, in order:

  1. Discover. Source adapters yield SourceRows, pre-catalog records that carry the query that produced them. The local builders read filenames and metadata directly.

  2. Index. A GeoCatalog Protocol implementation. Both backends expose the same surface: query, intersect, union, iter_rows, iter_slices, total_bounds, temporal_extent.

  3. Materialise. Loaders take a GeoSlice plus a catalog and return a carrier.

Source layout and public surface

The scoping report counted 11 files and about 3,600 lines from geotoolz’s catalog module. The current package is about 14,300 lines, organised into ten namespaces by workflow step (since 0.3.0), each a thin facade over geocatalog._src (Table 1):

Table 1:geocatalog’s public namespaces at 0.5.1. Names in italics need an extra and import lazily.

NamespacePublic names
geocatalog (root)GeoCatalog, GeoSlice, open_catalog, query, intersect, union
geocatalog.backendsInMemoryGeoCatalog, DuckDBGeoCatalog, CatalogRow, GeoCatalogError, CatalogMetadataError, CatalogSchemaError, CatalogClosedError
geocatalog.buildbuild_raster_catalog, build_vector_catalog, build_xarray_catalog, append_files
geocatalog.loadload_raster, aload_raster, load_raster_timeseries, load_vector, load_xarray
geocatalog.gridAlign, GridAlignmentWarning, count_steps, is_grid_aligned, slice_to_window, window_to_slice, PIXEL_PRECISION
geocatalog.storageto_geoparquet, from_geoparquet, migrate_geoparquet, sort_geoparquet, StreamingParquetWriter, CatalogBundle, QueryRecord, to_stac_collection, SCHEMA_VERSION_CURRENT
geocatalog.sourcesSource, SourceRow, AuthStatus, STACSource, EarthAccessSource, CMRSource, GEESource, from_stac_items, from_stac_search, source_row_to_gdf_row
geocatalog.matchupmatchup, MatchupRow; spatial strategies Intersects, Contains, CentroidWithin, IouAtLeast; temporal Synchronous, WithinWindow, NearestInTime
geocatalog.stagingstage
geocatalog.patchCatalogDomain, field_for
geocatalog.utilsparse_uri, ParsedURI, retry_transient_io, time helpers (to_naive_utc, to_rfc3339, …)

There is also a geocatalog command line, with build {raster, xarray, vector}, query, stats, migrate, convert and info.

GeoSlice, the unit of work

GeoSlice is a frozen dataclass of four fields: bounds, interval (a closed pd.Interval), resolution and crs, plus an align mode that is excluded from equality and hashing. Because it is frozen, a slice can be cached, hashed, sent across processes, or logged and replayed. To change one, use dataclasses.replace. The repo’s docs give the reason: “silent mutation of a query that’s been logged is the worst kind of bug”.

It carries everything the next stage needs to fetch a chip without consulting the catalog again: shape, height, width and transform are derived from it, and to_crs() reprojects it.

Grid alignment is the one subtlety. With bounds (x0,y0,x1,y1)(x_0, y_0, x_1, y_1) and resolution (Δx,Δy)(\Delta x, \Delta y), the pixel shape is

nx=round⁡(x1−x0Δx),ny=round⁡(y1−y0Δy),\begin{aligned} n_x &= \operatorname{round}\left(\frac{x_1 - x_0}{\Delta x}\right) , \\ n_y &= \operatorname{round}\left(\frac{y_1 - y_0}{\Delta y}\right) , \end{aligned}

which silently accepts an extent that is not a whole number of pixels ((1) rounds half up). That bites at matchup boundaries, where a chip is stacked against a label raster one pixel short. aligned_shape() is the strict version, and the align= mode chooses what construction does about a residual:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import pandas as pd
from geocatalog import GeoSlice
from geocatalog.grid import is_grid_aligned

iv = pd.Interval(pd.Timestamp("2024-06-01"), pd.Timestamp("2024-06-05"),
                 closed="both")
s = GeoSlice(bounds=(0.0, 0.0, 105.0, 60.0), interval=iv,
             resolution=(10.0, 10.0), crs="EPSG:32630")   # x-extent 105 = 10.5 px

s.shape            # → (6, 11)        rounds half up, silently
s.aligned_shape()  # ValueError: x-extent=105.0 is not an integer multiple of
                   # step=10.0 (nearest is 10 -> residual -5). Use align='snap' ...

snap = GeoSlice(bounds=(0.0, 0.0, 105.0, 60.0), interval=iv,
                resolution=(10.0, 10.0), crs="EPSG:32630", align="snap")
snap.bounds        # → (0.0, 0.0, 110.0, 60.0)   rounds outward, keeps the origin

t = GeoSlice(bounds=(5.0, 0.0, 65.0, 60.0), interval=iv,
             resolution=(10.0, 10.0), crs="EPSG:32630")
is_grid_aligned(s, t, explain=True)
# → {'aligned': False, 'crs_match': True, 'x_res_match': True,
#    'y_res_match': True, 'x_origin_residual': -5.0, 'y_origin_residual': 0.0}

Grid alignment at geocatalog 0.5.1, from a real run.

The modes are "off" (the default), "warn" (a GridAlignmentWarning), "error" and "snap". Snapping extends xmax right and ymin down, so the affine origin (xmin, ymax) never moves. Two slices are on the same pixel lattice when they share a CRS and resolution and their origins agree modulo the resolution. is_grid_aligned checks exactly that.

Why GeoSlice lives here

This decision was locked in by the scoping report, and the code bears it out:

  1. The catalog is the primary producer. catalog.iter_slices(...) and CatalogDomain.slices() yield GeoSlices, and every loader consumes one.

  2. The patcher does not use it. geopatcher’s unit is a Patch (data plus anchor plus neighbourhood), not a bounded request. geopatcher does not import geocatalog.

  3. A separate package would be over-engineering. GeoSlice is about 330 lines.

Table 2 shows who imports it: so far, only geocatalog and user code.

Table 2:Who imports GeoSlice, planned and at the pinned revisions.

ConsumerPlannedAt the pinned revisions
geocataloggeocatalog._srcgeocatalog._src.geoslice (exported from the root)
geotoolzcatalog-aware operators, samplers, CatalogPipelineno import; geotoolz’s ruff config bans geocatalog._src
xrtoolzcatalog-aware xarray loadersno import
geopatcherdoes not import itdoes not import it, as planned
user codefrom geocatalog import GeoSlicethe same

The row schema

Every backend shares one row schema (Table 3):

Table 3:The shared row schema, from the geocatalog repo’s concepts page.

ColumnTypeMeaning
geometryShapely polygonthe file’s footprint, in the catalog’s CRS
start_time, end_time (promoted to an IntervalIndex)timestampthe time interval, closed on both ends
filepathstrpath or URI to the source file
crsstrthe file’s own CRS, which may differ from the catalog’s

Each kind of catalog adds its own columns:

The IntervalIndex is what makes temporal queries cheap. Combined with an R-tree on geometry (in memory) or bbox-column pushdown (DuckDB), query(slice) is two cheap index lookups, intersected. Times are held as naive UTC throughout, and footprints ingested from STAC or CMR that cross the antimeridian are split into a MultiPolygon at ±180°.

Two backends, one Protocol

Both backends implement GeoCatalog, and the Protocol is the contract: anywhere code accepts a GeoCatalog, either one works. isinstance checks work against it. Table 4 compares the two, and Figure 2 shows where each one wins on latency.

Table 4:The two backends, from the geocatalog repo’s concepts page.

InMemoryGeoCatalogDuckDBGeoCatalog
Installbasethe [duckdb] extra
Storagea GeoDataFrame in memoryGeoParquet 1.1 on disk, S3 or Hugging Face
IndexingR-tree plus IntervalIndexGeoParquet 1.1 covering bbox column
Scaleup to about 10⁵ rows10⁶ rows and more
Remote URIsread through fsspecnative, through DuckDB’s httpfs
Mutationa new instance per operationa new instance per operation (a lazy SQL relation)
Buildeagerstreaming, in bounded memory
Log-log plot of query latency against catalog rows. The in-memory backend is flat and fast up to about ten to the five rows, then rises steeply. DuckDB starts slower and stays nearly flat. The curves cross around half a million rows.

Figure 2:Where each backend wins. From the geocatalog repo. The curves are illustrative, synthetic rather than measured; the shape is the point.

open_catalog(source, *, engine="auto", …) picks a backend for a GeoParquet artifact: DuckDB when the extra is installed, in memory otherwise. engine="duckdb" or "memory" forces one. A remote DuckDB catalog reads only the row groups a query touches. strict=True raises CatalogMetadataError on incomplete metadata instead of inferring it.

Set algebra

query, intersect and union all return new catalogs and leave their inputs untouched, so they are safe to compose and to cache (Figure 3).

Three panels. query returns the rows whose footprints overlap a slice. intersect returns the overlap of two catalogs, in space and time. union returns rows from either catalog, deduplicated by file path.

Figure 3:Catalog set algebra. From the geocatalog repo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import geopandas as gpd
import pandas as pd
from shapely.geometry import box

from geocatalog import GeoSlice, intersect, open_catalog, query, union
from geocatalog.backends import InMemoryGeoCatalog
from geocatalog.storage import from_geoparquet, to_geoparquet

gdf = gpd.GeoDataFrame(
    {"filepath": ["a.tif", "b.tif"],
     "start_time": pd.to_datetime(["2024-06-01", "2024-06-10"]),
     "end_time": pd.to_datetime(["2024-06-02", "2024-06-11"])},
    geometry=[box(0, 0, 100, 100), box(50, 50, 150, 150)],   # overlap in space,
    crs="EPSG:32630",                                         # not in time
)
cat = InMemoryGeoCatalog(gdf, kind="raster")
cat.total_bounds                  # → (0.0, 0.0, 150.0, 150.0)

s = GeoSlice(bounds=(0.0, 0.0, 60.0, 60.0),
             interval=pd.Interval(pd.Timestamp("2024-06-01"),
                                  pd.Timestamp("2024-06-05"), closed="both"),
             resolution=(10.0, 10.0), crs="EPSG:32630")
list(query(cat, s).gdf["filepath"])        # → ['a.tif']  b.tif misses the window

a = InMemoryGeoCatalog(gdf.iloc[[0]].copy(), kind="raster")
b = InMemoryGeoCatalog(gdf.iloc[[1]].copy(), kind="raster")
len(intersect(a, b))                       # → 0  disjoint in time
len(intersect(a, b, spatial_only=True))    # → 1  the (50, 50, 100, 100) overlap
len(union(a, b))                           # → 2

to_geoparquet(cat, "cat.parquet")          # GeoParquet 1.1, with a bbox column
len(from_geoparquet("cat.parquet"))        # → 2
open_catalog("cat.parquet", engine="memory")   # → an InMemoryGeoCatalog

An in-memory catalog end to end at geocatalog 0.5.1, from a real run.

intersect(…, spatial_only=True) is the tool for pairing imagery with static labels, which have no temporal overlap by construction.

Why the loaders live here

The scoping report kept the loaders in geocatalog rather than georeader or geotoolz, and that held:

load_raster reprojects each source into the slice’s CRS, range-reads remote files through GDAL, and has an async twin, aload_raster. load_raster_timeseries derives its time steps from the slice. The scoping report expected the loaders to be extras-gated; at 0.5.1 only the xarray and vector loaders are lazy, and the raster loader is part of the base install (Section ??).

What shipped beyond the scope

The scoping report described an index with builders and loaders. The current package adds the pieces that turn it into a data-acquisition tool. Most of them come from the repo’s query → matchup → patch design (Table 5):

Table 5:Shipped features the scoping report did not cover.

FeatureWhereWhat it does
Discovery sourcesgeocatalog.sourcesSTACSource, EarthAccessSource, CMRSource, GEESource behind one Source Protocol, yielding SourceRows with their query
Provenance bundlesgeocatalog.storage.CatalogBundleitems, query records and matchups persisted as one directory (ingest, write_matchups, to_directory, from_directory)
Matchupsgeocatalog.matchuppair rows across catalogs with a spatial strategy (Intersects, IouAtLeast, …) and a temporal one (Synchronous, WithinWindow, NearestInTime)
Staginggeocatalog.staging.stagedownload a catalog’s assets in parallel with retries, through geocloud’s LocalCache
Exact grid alignmentgeocatalog.gridalign= modes, aligned_shape, is_grid_aligned (Section ??)
Hive partitioning and appendsto_geoparquet(partition_by=…), build.append_filesarchives that grow; only new rows are written
Schema versionsstorage.migrate_geoparquet, SCHEMA_VERSION_CURRENT (0)forward migrations of the GeoParquet schema
STAC exportstorage.to_stac_collectiona catalog as a STAC collection
Remote I/Outils.parse_uri, utils.retry_transient_ioURI resolution through GDAL /vsi paths or geocloud’s filesystems; retries with backoff
CLIgeocatalog …build, query, stats, migrate, convert, info

Dependencies and extras

The scoping report wanted an index-only install with no GDAL: pandas, geopandas, pyproj, shapely, and rasterio (for GeoSlice’s affine transforms and windows). Loaders, DuckDB and streaming would be extras. Table 6 and Table 7 set that against what shipped.

Table 6:The base install, scoped and shipped.

Scoped baseShipped base at 0.5.1
pandas, geopandas, pyproj, shapely, rasteriopandas, geopandas, pyproj, shapely, rasterio, georeader-spaceml, pyarrow, numpy, typing-extensions, loguru, cyclopts, tenacity

Table 7:The optional extras, scoped and shipped.

ScopedShipped
duckdb = [duckdb]duckdb = [duckdb>=1.1] (1.1 for bbox-column pushdown)
raster = [georeader]none: georeader is in the base
xarray = [xarray, rioxarray]xarray-raster = [xarray, rioxarray, h5netcdf, zarr]
vector = [pyogrio]none: pyogrio arrives with geopandas
streaming = [pyarrow]streaming = [duckdb]: pyarrow is in the base
—cloud = [geotoolz-cloud[fsspec]], stac, earthaccess, gee, sources-all, patch = [geotoolz-patcher>=0.6], full

No pipekit dependency. This held exactly, and it is the cleanest decision in the report. The catalog is not an Operator; it is a queryable index. pipekit composes operators that transform a carrier, and geocatalog finds which files match a query. You can open a catalog without pipekit, and a pipekit pipeline can read from a source that is not a catalog. The two meet in the consumer libraries, which is where the missing operators belong (Section ??).

The patcher bridge

CatalogDomain lets a geopatcher patcher walk a catalog without either package importing the other, and it shipped as designed in that respect. geopatcher’s Domain is a runtime_checkable Protocol with two members, crs and bounds. CatalogDomain has both, so it satisfies Domain structurally: isinstance(CatalogDomain(...), geopatcher.Domain) returns True at runtime, and neither package imports the other at module level.

1
2
3
4
5
from geocatalog.patch import CatalogDomain

dom = CatalogDomain(cat, resolution=(10.0, 10.0))
dom.bounds, dom.crs.to_epsg()    # → ((0.0, 0.0, 150.0, 150.0), 32630)
len(dom.slices())                # → 2  one GeoSlice per catalog row

CatalogDomain at geocatalog 0.5.1, from a real run on the catalog above.

field_for is the second half of the bridge, which the scoping report did not have. With the [patch] extra it hands a catalog to geopatcher as a RasterField (or a list of them), importing geopatcher lazily inside the call:

1
2
3
4
from geocatalog.patch import field_for

field = field_for(catalog, slice_, asset="visual")   # → geopatcher.RasterField
# geopatcher's SpatialPatcher then splits `field` into patches.

Catalog-driven iteration meeting patch-driven iteration (a sketch; needs the [patch] extra).

Migration from geotoolz

The scoping report planned a transitional period:

1
2
3
4
5
6
class CatalogPipeline(Operator):
    """ETL driver: iterate a catalog, apply an op per scene, maybe in parallel."""
    def __init__(self, catalog: GeoCatalog, op: Operator, *, n_workers=1): ...

class CatalogReader(Operator):
    """Read one GeoSlice into a GeoTensor, inline in a Sequential."""

The consumer operators the scoping report planned for geotoolz (not built).

The move happened. The rest did not (Table 8):

Table 8:The migration steps at the pinned revisions.

StepStatus
Catalog code moves out of geotoolz into its own packagedone: geocatalog, now geotoolz-catalog in the same monorepo
GeoSlice becomes geocatalog.GeoSlicedone
geotoolz re-exports the catalog (gz.open_catalog, gz.catalog, geotoolz.types.GeoSlice)not done; geotoolz has no catalog names and no types module
Deprecation over two minor versionsnot needed: there was no compatibility layer to deprecate
CatalogPipeline, CatalogReader in geotoolz.catalog_opsnot built
xrtoolz depends on geocatalognot done

The effect is that geocatalog is used on its own: you open a catalog and load slices yourself, then hand the results to geotoolz or xrtoolz operators. The scoping report’s cross-library examples still work in that form, with today’s names:

1
2
3
4
5
6
7
8
9
10
11
from geocatalog import open_catalog
from geocatalog.load import load_raster, load_xarray

cat = open_catalog("s3://bucket/scenes.parquet")      # DuckDB if installed
for sl in cat.iter_slices(resolution=(60.0, 60.0)):   # one GeoSlice per row
    gt = load_raster(cat, sl)                         # → GeoTensor, for geotoolz
    ...

for sl in reanalysis.iter_slices(resolution=(0.25, 0.25)):
    ds = load_xarray(reanalysis, sl)                  # → xr.Dataset, for xrtoolz
    ...

One catalog feeding both domain libraries, in today’s names (a sketch).

CatalogPipeline is now assumed by three reports (2, 3 and this one) and by use cases 2 and 12, and it exists nowhere. That makes it the most important open item here.

The monorepo development model

The scoping report chose to develop everything in one “pipekit-ecosystem” monorepo with uv workspaces and publish separate packages. The reasons:

What exists is three monorepos, each a uv workspace with per-package releases (Table 9; Report 16 maps every distribution in them):

Table 9:The workspaces at the pinned revisions.

WorkspacePackages
pipekitpipekit, pipekit-array, pipekit-cycle, pipekit-evaluate, pipekit-experiment, pipekit-jax, pipekit-train
geotoolzgeotoolz, geotoolz-catalog (geocatalog), geotoolz-cloud, geotoolz-patcher, geotoolz-products
xrtoolzxrtoolz, xrtoolz-core, xrtoolz-einx, xrtoolz-grad, xrtoolz-reader, xrtoolz-sklearn

The operating pattern held inside each workspace. Packages resolve to local paths during development, and release-please cuts per-package tags (geotoolz-catalog-v0.5.1). Cross-package pins appear in published metadata, for example patch = [geotoolz-patcher>=0.6]. The catalog landed in the geotoolz workspace, next to the patcher and cloud I/O it works with most, rather than beside pipekit. geostack itself is where all three workspaces meet (as submodules in references/).

Effort and timing

The scoping report estimated a week of focused work, done in one coordinated v1.0 release alongside the pipekit and geopatcher extractions, so users would absorb one breaking change rather than three. The record is in Table 10.

Table 10:geocatalog’s history.

WhenWhat
May 2026standalone geocatalog repo, through 0.0.2 (25 May): the index and both backends, builders, loaders, sources, bundles, matchups, staging
late May – June 2026Hive partitioning, fsspec URIs, retries, STAC builders, obstore, exact grid alignment (0.0.3, 9 June)
13 July 2026geocatalog and geopatcher merge into the geotoolz monorepo (geotoolz-catalog 0.1.0)
8 October 20260.3.0: organised by workflow step; flat re-exports removed (breaking)
12 October 20260.5.1, the pinned revision

There was no single v1.0. The extraction was gradual, with breaking changes absorbed per package (geocatalog 0.3.0, geopatcher’s matching reorganisation). geotoolz is at 0.10.2. What the split unblocked, as the report hoped: catalog backends, schema migrations and new sources released on their own cycle. There have been twelve geotoolz-catalog releases since July (0.1.0 to 0.5.1), each tagged on its own.

Trade-offs

The scoping report’s list, with how each turned out.

What got better:

  1. Discoverability, partly. The repo and the import name are geocatalog, but the distribution from the monorepo is geotoolz-catalog, which is not what someone searching for a catalog would find (Section ??).

  2. A minimal install for narrow use: did not happen (Section ??).

  3. GeoSlice as a cross-library type: it is geocatalog.GeoSlice, but no other library imports it yet.

  4. Independent backend work: yes. DuckDB parity, GeoParquet 1.1 pushdown and schema migrations all shipped in catalog releases.

What got worse, and the mitigations:

  1. One more package to release: release-please makes it one tag.

  2. Version pinning between packages: largely moot, because geotoolz does not depend on geocatalog yet.

  3. Documentation has to live somewhere: it lives in the standalone repo (mkdocs, with the figures and the Lake Tahoe notebook), separate from the code since July.

  4. The Protocol bridge is implicit: documented in the repo’s concepts page, and checkable with isinstance.

Open questions

  1. Which repo is canonical? The code lives in the geotoolz monorepo; the docs, figures and notebook live in the standalone repo, frozen at 0.0.3. Either the docs move into the monorepo (and the standalone repo is archived with a pointer), or the standalone repo becomes the docs site for geotoolz-catalog.

  2. geocatalog or geotoolz-catalog on PyPI? Resolved: geotoolz-catalog (Report 16 §F).

  3. Should there be an index-only install? The scoped base had no georeader, and no rasterio beyond what GeoSlice needs. If the narrow use case matters, georeader and the raster loader move behind an extra.

  4. Where do CatalogPipeline and CatalogReader live? In geotoolz, as scoped, which needs geotoolz to depend on geocatalog. Or in an optional geocatalog[pipekit] module, which breaks the no-pipekit rule only behind an extra. Reports 2 and 3 both depend on the answer.

  5. Should geotoolz and xrtoolz adopt geocatalog at all? Today the catalog is used standalone and the domain libraries never see a GeoSlice. The scoped re-exports may not be worth a dependency; catalog-aware operators might be.

  6. Is slices() the right bridge? CatalogDomain.slices() returns every row’s slice eagerly. The scoped rows_for(anchor) was lazy and query-driven, which matters for large catalogs walked by a patcher.

  7. A third backend? The scoping report floated PostGIS. Nothing in the design notes picks it up; DuckDB over GeoParquet covers the scale cases so far.

Upcoming work

Table 11 lists the follow-ups.

Table 11:Follow-ups from checking the scoping report against the repo and the code.

Follow-upWhereWhy
Fix the data-flow figure: the arrowheads cover the box labelsgeocatalog repo, docs/assets/make_diagrams.pythis page redraws it instead
Regenerate the Lake Tahoe NDVI composite, which renders as an empty plotgeocatalog repo, docs/assets/lake_tahoe_ndvi_composite.pngthe quickstart’s result figure shows no data
Replace the illustrative backend curves with measured onesgeocatalog, from tests/bench/the figure says “synthetic, not measured”
Update the docs’ examples to the namespaced 0.3.0 APIgeocatalog docsgc.load_raster, gc.to_geoparquet no longer exist
Point the concepts page at geopatcher.SpatialPatchergeocatalog docsit names geotoolz.patch.SpatialPatcher
Remove the [vector] extra from docstringsgeocatalog._src.formats.vectorthe extra is not defined
Index geocatalog’s namespaces in the capability indexgeostack scripts/capabilities.pyonly the root’s six names are listed (Report 3)
Build CatalogPipeline / CatalogReader once question 4 is settledgeotoolz or geocataloguse cases 2 and 12; Reports 2, 3 and 6

References

Sources

Citations to be added: GeoParquet 1.1, DuckDB, STAC, and NASA CMR, as entries in references.bib.