Replace your crawler¶
Umbra's open data is a public bucket
(umbra-open-data-catalog, prefixes sar-data/tasks/,
sar-data/task-data/, and open-data/) full of STAC sidecars. Listing that
tree yourself is the crawl this project already runs once a week. The result
is a STAC API and one Parquet file. Both answer "which GEC scenes cover this
box?" without a bucket walk.
The community API is https://api.umbra-py.space/.
GET and POST /search are STAC API Item Search. The only collection is
umbra-open-data.
Why the index¶
Freshness. The snapshot is what Umbra has published in the open bucket,
rebuilt on a weekly cadence. Open data is a curated batch, so the newest
acquisition in the index lags the calendar. GET /healthz reports built_at
and items. The collection's temporal extent is the same window. On
2026-10-15 the API was serving a snapshot built that day: 17,205 items,
acquisitions from 2023-01-16 through 2026-09-13.
Weekly rebuild. The
Publish catalog index
workflow runs Mondays at 06:17 UTC and replaces the rolling GitHub release
catalog-index.
That release holds catalog.db, umbra-open-data.parquet, the validation
report, and coverage.json.
Validation. umbra index build drops rows that cannot be published:
duplicate ids (latest updated, otherwise datetime, then the most assets),
a datetime before 2018-01-01 or more than a day ahead, a missing or invalid
footprint, no public HTTPS asset href, or any href into the private
prod-prod-processed bucket. The dropped rows are
index-validation.json
on the release. The 2026-10-15 report kept 17,205 rows, quarantined 8, dropped
747 duplicate ids, and recorded no remaining violations.
Coverage.
coverage.json
measures the bucket against the index, by prefix (sar-data/tasks/,
sar-data/task-data/, open-data/), sidecar layout, and product. The
2026-10-15 report indexed 17,205 of 17,960 indexable sidecars (95.8%).
Sidecars the walk did not take are listed there; quarantined rows are not
counted as missing.
/healthz built_at and the release notes are the live copy of these
numbers. The dates above are the snapshot this page was checked against.
Asset keys¶
Asset schema v2 keys each product by a stable name. The original filename is
umbra-py:filename on the asset (and title, when the asset had none). A
second file of the same product is gec_2, gec_3, … in filename order.
The canonical *_GEC.tif / *_CPHD.cphd / … name keeps the unsuffixed key.
umbra-py:asset_schema is 2 on the collection, on each item, and (once the
parquet is rebuilt) in the Parquet file metadata under stac-geoparquet →
collections → umbra-open-data.
| Key | File |
|---|---|
gec |
*_GEC.tif, or a legacy bare <stem>.tif |
csi |
*_CSI.tif |
sidd |
*_SIDD.nitf |
csi_sidd |
*_CSI-SIDD.nitf |
sicd |
*_SICD.nitf |
cphd |
*_CPHD.cphd, or a legacy bare <stem>.cphd |
metadata |
*_METADATA.json |
stac_metadata |
*_STAC-METADATA.json |
di_gif |
*_DI-GIF.gif |
The API already serves these keys: /search rewrites an older index row on
the way out. The umbra-open-data.parquet asset currently on catalog-index
is still the previous export. Its assets column is a struct with one field
per filename, and its stac-geoparquet metadata is {"version": "1.0.0"}
with no umbra-py:asset_schema. The DuckDB program below is the v2 shape.
It starts working when the next weekly rebuild publishes the file. Until
that rebuild, /search is the interface that already returns v2 keys.
A file that matches none of the keys is file or file_<ext>. The longer
note, including how umbra-py:products lines up with the keys, is
Local index.
The same search, three ways¶
Each program lists GEC acquisitions whose footprint intersects longitude
85.49–85.56 and latitude 28.26–28.32, on or after 2026-09-01, and prints the
GEC href. That box is the September 2026 Nepal disaster coverage, which the
2026-10-15 snapshot actually contains. limit is a page of 5. The index
orders by task, then acquisition date, then datetime, then id — a page is the
first matches in that order, which inside one bbox is the earliest passes.
The parameters are the ones /search implements:
bbox—minlon,minlat,maxlon,maxlat(GET) or a four-number array (POST).datetime—2026-09-01/..is an open interval from that date. A single date matches that day. The index filters on the acquisition date.collections—umbra-open-data. Any other id is a 400.product_types—GEC(comma-separated on GET). Case-insensitive. This is the product type, uppercase, which is a different string from the asset keygec.limit— page size. Default 10, maximum 10000. The next page istoken(the offset) on thenextlink.
The programs are the files the daily canary runs. They live in
docs/examples/replace_crawler/.
Plain HTTP¶
GET /search returns a GeoJSON FeatureCollection. The GEC href is
features[].assets.gec.href.
import json
import urllib.request
url = (
"https://api.umbra-py.space/search"
"?bbox=85.49,28.26,85.56,28.32"
"&datetime=2026-09-01/.."
"&product_types=GEC"
"&collections=umbra-open-data"
"&limit=5"
)
for feature in json.load(urllib.request.urlopen(url))["features"]:
print(feature["assets"]["gec"]["href"])
pystac-client¶
Client.search speaks POST /search. The product filter is the STAC Query
extension body this API accepts, {"product_types": {"in": ["GEC"]}},
passed as query=. max_items stops the client after five items; limit
is the page size it requests.
from pystac_client import Client
search = Client.open("https://api.umbra-py.space/").search(
collections=["umbra-open-data"],
bbox=[85.49, 28.26, 85.56, 28.32],
datetime="2026-09-01/..",
query={"product_types": {"in": ["GEC"]}},
limit=5,
max_items=5,
)
for item in search.items():
print(item.assets["gec"].href)
DuckDB¶
The Parquet file is stac-geoparquet on the catalog-index release:
https://github.com/reesehammer/umbra-py/releases/download/catalog-index/umbra-open-data.parquet
bbox is a struct (xmin, ymin, xmax, ymax). Overlap with a query
box is bbox.xmax >= minlon AND bbox.xmin <= maxlon (and the same for y).
datetime is TIMESTAMPTZ. Comparing it with CAST(datetime AS DATE)
keeps the filter in SQL. assets is one struct whose fields are the v2
keys. Each field is itself a struct: href, type, title, roles, and
umbra-py:filename. The href is assets.gec.href. The original filename is
assets.gec."umbra-py:filename" (quote the field; the name contains a
colon). httpfs reads the release URL. GitHub redirects that stable
/releases/download/catalog-index/ path to the current asset.
import duckdb
con = duckdb.connect()
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
rows = con.execute(
"""
SELECT assets.gec.href
FROM read_parquet(?)
WHERE bbox.xmax >= 85.49 AND bbox.xmin <= 85.56
AND bbox.ymax >= 28.26 AND bbox.ymin <= 28.32
AND CAST(datetime AS DATE) >= DATE '2026-09-01'
AND assets.gec.href IS NOT NULL
LIMIT 5
""",
[
"https://github.com/reesehammer/umbra-py/releases/download/"
"catalog-index/umbra-open-data.parquet"
],
).fetchall()
for (href,) in rows:
print(href)
On the schema-v1 file the assets fields are filenames, so assets.gec is
absent and this query errors. The daily canary reads the file metadata
first and skips this program until umbra-py:asset_schema is 2, or a field
named gec exists. The HTTP and pystac-client programs run either way.
One copy of a republished collect¶
The same collect is often published twice: once under sar-data/task-data/
and once under sar-data/tasks/<campaign>/. Those are different STAC ids.
The index keeps both. After the next rebuild, each item has
umbra-py:canonical. true is the copy to keep (newest created, then
processor version, datetime, product count, a task-data href, then the
lowest id — see Local index).
Items that are not a republished pair are canonical too, so the filter is
one boolean.
These three programs are the same Nepal search limited to that copy. They
are not the daily canary: the canary hits the API and parquet that are
already published, and this filter is on the snapshot this change builds.
GET /search?canonical=true is the HTTP form. pystac-client sends it as
a query-extension predicate. DuckDB quotes "umbra-py:canonical" because
the column name contains a colon. The column is absent until the weekly
parquet is rebuilt from an index that has been linked.
import json
import urllib.request
url = (
"https://api.umbra-py.space/search"
"?bbox=85.49,28.26,85.56,28.32"
"&datetime=2026-09-01/.."
"&product_types=GEC"
"&collections=umbra-open-data"
"&canonical=true"
"&limit=5"
)
for feature in json.load(urllib.request.urlopen(url))["features"]:
print(feature["id"], feature["properties"]["umbra-py:canonical"])
from pystac_client import Client
search = Client.open("https://api.umbra-py.space/").search(
collections=["umbra-open-data"],
bbox=[85.49, 28.26, 85.56, 28.32],
datetime="2026-09-01/..",
query={"umbra-py:canonical": {"eq": True}, "product_types": {"in": ["GEC"]}},
limit=5,
max_items=5,
)
for item in search.items():
print(item.id, item.properties["umbra-py:canonical"])
import duckdb
con = duckdb.connect()
con.execute("INSTALL httpfs")
con.execute("LOAD httpfs")
rows = con.execute(
"""
SELECT id
FROM read_parquet(?)
WHERE bbox.xmax >= 85.49 AND bbox.xmin <= 85.56
AND bbox.ymax >= 28.26 AND bbox.ymin <= 28.32
AND CAST(datetime AS DATE) >= DATE '2026-09-01'
AND "umbra-py:canonical" = true
LIMIT 5
""",
[
"https://github.com/reesehammer/umbra-py/releases/download/"
"catalog-index/umbra-open-data.parquet"
],
).fetchall()
for (item_id,) in rows:
print(item_id)
Imagery hrefs point at Umbra's public bucket. Attribute Umbra under
CC BY 4.0. The API rate limit is 120 requests per minute per client.