Crosswalking ISO 19115 to STAC Item Properties
Map the ISO record’s identification block to STAC Item properties, project its geographic extent into a WGS 84 footprint and bounding box, and accept that most of the ISO document has nowhere to go — a STAC Item is an index entry for an asset, not a metadata record, and treating it as one produces Items nothing can search.
The two standards are shaped by different purposes and the crosswalk between them is lossy by design in one direction and impossible in the other. ISO 19115 describes a dataset exhaustively for a catalogue; STAC describes an asset minimally for a search index, with everything else pushed into extensions. Understanding which direction you are going, and why, decides most of the design. This guide is part of metadata crosswalks and field mapping tables, under Automated Metadata Generation & Schema Mapping.
Automated Python Implementation
The crosswalk below reads an ISO 19115-3 document and emits a STAC Item. Its most important behaviour is that it refuses to emit an Item whose geometry it could not establish, because an Item without a correct footprint is invisible to the search it exists to serve.
#!/usr/bin/env python3
"""Crosswalk an ISO 19115-3 record to a STAC Item."""
from lxml import etree
NS = {
"mdb": "http://standards.iso.org/iso/19115/-3/mdb/2.0",
"cit": "http://standards.iso.org/iso/19115/-3/cit/2.0",
"mri": "http://standards.iso.org/iso/19115/-3/mri/1.0",
"gex": "http://standards.iso.org/iso/19115/-3/gex/1.0",
"gco": "http://standards.iso.org/iso/19115/-3/gco/1.0",
}
def text_at(root, xpath):
found = root.xpath(xpath, namespaces=NS)
return found[0].text.strip() if found and found[0].text else None
def bbox_from_iso(root):
"""Read the geographic bounding box; ISO gives it in decimal degrees."""
def value(tag):
return text_at(root, f".//gex:EX_GeographicBoundingBox/gex:{tag}/gco:Decimal")
west, east = value("westBoundLongitude"), value("eastBoundLongitude")
south, north = value("southBoundLatitude"), value("northBoundLatitude")
if None in (west, east, south, north):
return None
return [float(west), float(south), float(east), float(north)]
def polygon_from_bbox(bbox):
west, south, east, north = bbox
return {
"type": "Polygon",
"coordinates": [[
[west, south], [east, south], [east, north], [west, north], [west, south],
]],
}
def datetime_from_iso(root):
"""STAC requires a single instant, or an explicit start/end pair."""
begin = text_at(root, ".//gex:EX_TemporalExtent//*[local-name()='beginPosition']")
end = text_at(root, ".//gex:EX_TemporalExtent//*[local-name()='endPosition']")
instant = text_at(root, ".//gex:EX_TemporalExtent//*[local-name()='timePosition']")
if instant:
return {"datetime": instant}
if begin and end:
return {"datetime": None, "start_datetime": begin, "end_datetime": end}
return {"datetime": None}
def iso_to_stac_item(xml_path, item_id, assets, licence="proprietary"):
root = etree.parse(xml_path).getroot()
bbox = bbox_from_iso(root)
if bbox is None:
raise ValueError(f"{xml_path}: no geographic bounding box; cannot emit an Item")
properties = {
"title": text_at(root, ".//mri:MD_DataIdentification//cit:title/gco:CharacterString"),
"description": text_at(root, ".//mri:abstract/gco:CharacterString"),
"license": licence,
"iso:source_record": text_at(root, ".//mdb:metadataIdentifier//gco:CharacterString"),
}
properties.update(datetime_from_iso(root))
properties = {k: v for k, v in properties.items() if v is not None}
return {
"type": "Feature",
"stac_version": "1.0.0",
"id": item_id,
"bbox": bbox,
"geometry": polygon_from_bbox(bbox),
"properties": properties,
"links": [],
"assets": assets,
}
Two decisions in that code are the ones that separate a usable Item from a valid but useless one.
A missing bounding box is fatal, not defaulted. The temptation is to emit an Item with a world-extent footprint so the run completes. That Item will match every spatial query and be relevant to none of them, which is worse for a search index than being absent. Raising forces the gap into the residue list where it can be fixed.
The temporal extent becomes either an instant or an explicit range. STAC allows a null datetime only when start_datetime and end_datetime are both present, and a great many ISO records describe a period rather than a moment. Collapsing a period to its start — a common shortcut — makes a decade-long survey appear as a single day in every temporal search.
Validation and Pipeline Integration
Assert the two properties that make an Item findable, and the one that keeps it honest about its source.
def test_bbox_and_geometry_agree():
item = iso_to_stac_item("fixtures/record.xml", "test", {})
ring = item["geometry"]["coordinates"][0]
xs = [pt[0] for pt in ring]
ys = [pt[1] for pt in ring]
assert [min(xs), min(ys), max(xs), max(ys)] == item["bbox"]
def test_period_is_not_collapsed_to_an_instant():
item = iso_to_stac_item("fixtures/survey_period.xml", "test", {})
props = item["properties"]
assert props.get("start_datetime") and props.get("end_datetime")
assert "datetime" not in props or props["datetime"] is None
def test_record_without_extent_is_refused():
try:
iso_to_stac_item("fixtures/no_extent.xml", "test", {})
except ValueError:
return
raise AssertionError("an Item without a footprint must not be emitted")
Run the crosswalk as part of catalogue publication rather than as a one-off migration, and keep the ISO record as the source. The Item is an index entry derived from it; regenerating the index is cheap, and regenerating the ISO record from an Item is impossible.
The Reverse Direction Is Not a Crosswalk
Generating an ISO 19115 record from a STAC Item is occasionally requested and should generally be refused, because the Item does not contain the information the ISO record requires.
An ISO record needs an abstract, a responsible party with a role, a metadata contact, a reference system identifier, a maintenance frequency and a lineage statement. A STAC Item carries a description, possibly a provider list, and nothing else on that list. Producing a valid ISO record from it therefore means supplying six mandatory elements from defaults — and a record composed mostly of pipeline defaults is a record that asserts things nobody checked.
Where the requirement is real — a portal that only accepts ISO, fed by a pipeline that only produces STAC — the honest arrangement is to treat the missing elements as a data-entry task rather than a transformation. Generate a skeleton with the derivable fields filled and the rest explicitly empty, and route it to whoever can supply them. That is slower and it produces records that mean something.
The alternative, seen often enough to be worth naming, is a converter that fills the gaps with organisational defaults and emits records that all claim the same contact, the same maintenance frequency and a lineage statement reading “Generated automatically”. Those records pass validation and degrade the catalogue they are added to, because a searcher cannot distinguish them from records that were actually described.
Long-Term Compliance Best Practices
- Keep the ISO identifier in the Item properties. A namespaced property such as
iso:source_recordcosts nothing and makes the Item traceable to the record it summarises. - Resolve the licence to an SPDX identifier or say
proprietary. STAC’slicensefield expects one of those; writing a sentence of use constraints into it produces a value no consumer can act on. - Emit a
describedbylink to the full record. The information that did not fit in the Item is exactly what a searcher wants after finding it. - Regenerate Items on every catalogue publication. They are derived artifacts; treating them as durable means a corrected ISO record and a stale Item can coexist indefinitely.
- Never widen a footprint to make a record fit. An Item with an invented extent pollutes every spatial query it matches.
Related
- Metadata Crosswalks & Field Mapping Tables — the rule format and applier this crosswalk fits into
- STAC Catalog Metadata Automation — Item, Collection and Catalog structure in full
- ISO 19115 Metadata Template Generation — producing the source records this reads
- ISO 19115 vs DCAT-AP: When to Use Each — the same one-record-many-profiles argument for a different pair