Generating ISO 19115 Lineage Statements Automatically Jump to heading
A metadata catalogue does not read Parquet. Auditors, INSPIRE portals, and ISO-conformant geoportals expect provenance expressed as ISO 19115-1 LI_Lineage — a human-readable statement plus an ordered list of LI_ProcessStep elements — serialized as ISO 19139 XML. This guide converts the rows produced by Lineage Manifest Generation into exactly that structure using lxml, validates it against the ISO schema, and embeds it in a MD_Metadata record. It is the rendering step that makes an internal audit trail publishable as standards-compliant metadata.
The mapping is mechanical but unforgiving: ISO 19139 is namespace-heavy, element order is schema-enforced, and a single misplaced gco: wrapper makes the whole record invalid against the XSD. Automating it removes the transcription errors that plague hand-authored lineage and guarantees that every published statement traces back to a specific, immutable manifest row. If your source metadata is FGDC rather than born-ISO, the crosswalk in converting FGDC CSDGM to ISO 19115 automatically produces the surrounding record this lineage slots into; both are facets of the Geospatial Schema Architecture & Standards Mapping discipline.
Prerequisites checklist Jump to heading
Step 1: Read and group lineage rows Jump to heading
Load the manifest for a single run and reduce it to the events that belong in published lineage. Not every internal row is a process step — a portal wants meaningful stages (reprojection, attribute casting, validation), not one entry per feature. Group by stage and event_type, order by sequence, and collapse per-feature rows into one process step per logical stage with a count.
# iso_lineage/read.py — python 3.10+, pyarrow >=14
from __future__ import annotations
from dataclasses import dataclass
import pyarrow.dataset as ds
import pyarrow.compute as pc
@dataclass(slots=True)
class ProcessStepInput:
stage: str
event_type: str
outcome: str
first_time_utc: str # ISO 8601
feature_count: int
tool_version: str | None
def load_process_steps(manifest_root: str, run_id: str) -> list[ProcessStepInput]:
"""Read one run's lineage and reduce it to ordered, published process steps."""
dataset = ds.dataset(manifest_root, format="parquet", partitioning="hive")
table = dataset.to_table(filter=ds.field("run_id") == run_id)
if table.num_rows == 0:
raise ValueError(f"No lineage rows for run_id={run_id!r}; nothing to publish.")
# Sort so the earliest event per stage anchors its process-step timestamp.
order = pc.sort_indices(table, sort_keys=[("sequence", "ascending")])
table = table.take(order)
grouped: dict[tuple[str, str], ProcessStepInput] = {}
for row in table.to_pylist():
key = (row["stage"], row["event_type"])
if key not in grouped:
grouped[key] = ProcessStepInput(
stage=row["stage"],
event_type=row["event_type"],
outcome=row["outcome"],
first_time_utc=row["event_time_utc"].isoformat(),
feature_count=0,
tool_version=row.get("tool_version"),
)
grouped[key].feature_count += 1
return list(grouped.values())
Step 2: Map fields to LI_ProcessStep Jump to heading
Each grouped stage becomes one LI_ProcessStep. The mapping from lineage fields to ISO elements is the contract of this whole guide, so make it explicit:
| Lineage field | ISO 19115-1 target | Notes |
|---|---|---|
stage + event_type |
LI_ProcessStep/description (gco:CharacterString) |
Human-readable action, e.g. “CRS reprojection (TRANSFORM)” |
first_time_utc |
LI_ProcessStep/dateTime (gco:DateTime) |
Earliest event time for the stage; ISO 8601 |
tool_version |
LI_ProcessStep/processingInformation rationale |
PROJ/pyproj build string as the reproducibility anchor |
feature_count + outcome |
LI_ProcessStep/rationale (gco:CharacterString) |
e.g. “1,204 features, outcome PASS” |
stage |
LI_ProcessStep/processor → CI_ResponsibleParty |
The software agent that ran the stage |
Build each element under the correct namespaces. ISO 19139 places the values inside gco: wrappers, so a bare string is invalid — the value must be <gco:CharacterString>...</gco:CharacterString>.
# iso_lineage/build.py — python 3.10+, lxml >=5.0
from __future__ import annotations
from lxml import etree
from iso_lineage.read import ProcessStepInput
NSMAP = {
"gmd": "http://www.isotc211.org/2005/gmd",
"gco": "http://www.isotc211.org/2005/gco",
}
def _gco_char(parent: etree._Element, tag: str, value: str) -> etree._Element:
"""Append <gmd:tag><gco:CharacterString>value</gco:CharacterString></gmd:tag>."""
el = etree.SubElement(parent, f"{{{NSMAP['gmd']}}}{tag}")
cs = etree.SubElement(el, f"{{{NSMAP['gco']}}}CharacterString")
cs.text = value
return el
def build_process_step(step: ProcessStepInput) -> etree._Element:
"""Render one reduced lineage stage as an ISO 19115-1 LI_ProcessStep."""
ps = etree.Element(f"{{{NSMAP['gmd']}}}LI_ProcessStep", nsmap=NSMAP)
description = f"{step.stage} ({step.event_type})"
_gco_char(ps, "description", description)
rationale = f"{step.feature_count} features, outcome {step.outcome}"
_gco_char(ps, "rationale", rationale)
date_el = etree.SubElement(ps, f"{{{NSMAP['gmd']}}}dateTime")
dt_el = etree.SubElement(date_el, f"{{{NSMAP['gco']}}}DateTime")
dt_el.text = step.first_time_utc
if step.tool_version:
proc = etree.SubElement(ps, f"{{{NSMAP['gmd']}}}processingInformation")
_gco_char(proc, "identifier", step.tool_version)
return ps
Step 3: Build the LI_Lineage tree Jump to heading
Assemble the process steps under a single LI_Lineage element, prefixed by a statement that summarizes provenance in prose. The statement is what a human reads first; the process steps are the machine-auditable detail. Element order matters: statement precedes processStep in the ISO content model.
# iso_lineage/build.py (continued) — python 3.10+, lxml >=5.0
def build_lineage(steps: list[ProcessStepInput], statement: str) -> etree._Element:
"""Assemble an ISO 19115-1 LI_Lineage from ordered process steps."""
lineage = etree.Element(f"{{{NSMAP['gmd']}}}LI_Lineage", nsmap=NSMAP)
# statement MUST come before any processStep per the ISO content model.
_gco_char(lineage, "statement", statement)
for step in steps:
wrapper = etree.SubElement(lineage, f"{{{NSMAP['gmd']}}}processStep")
wrapper.append(build_process_step(step))
return lineage
Step 4: Validate against the ISO schema Jump to heading
Never publish unvalidated ISO XML — a portal will reject it, and a silently malformed record breaks the audit trail it was meant to prove. Validate against the ISO 19139 XSD with lxml.etree.XMLSchema before the record leaves your process. Because LI_Lineage is a fragment, validate it inside a minimal dataQualityInfo envelope that the schema recognizes, or validate the whole MD_Metadata after embedding (Step 5).
# iso_lineage/validate.py — python 3.10+, lxml >=5.0
from __future__ import annotations
import logging
from pathlib import Path
from lxml import etree
logger = logging.getLogger(__name__)
class LineageValidationError(Exception):
"""Raised when generated lineage XML fails ISO 19139 schema validation."""
def load_schema(xsd_path: Path) -> etree.XMLSchema:
"""Compile the ISO 19139 XSD once; reuse across many records."""
try:
return etree.XMLSchema(etree.parse(str(xsd_path)))
except etree.XMLSchemaParseError as exc:
logger.critical("Could not compile ISO XSD at %s: %s", xsd_path, exc)
raise
def validate_document(doc: etree._Element, schema: etree.XMLSchema) -> None:
"""Validate a full MD_Metadata document; raise with the first schema error."""
if not schema.validate(doc):
errors = "; ".join(str(e.message) for e in schema.error_log)
raise LineageValidationError(f"ISO 19139 validation failed: {errors}")
logger.info("ISO 19139 validation passed.")
Step 5: Embed in the metadata record Jump to heading
Insert the validated LI_Lineage into the record’s MD_Metadata/dataQualityInfo/DQ_DataQuality/lineage slot, then serialize. Placing it under DQ_DataQuality is what ties provenance to the dataset’s declared quality scope — the structure INSPIRE and ISO auditors navigate to.
# iso_lineage/embed.py — python 3.10+, lxml >=5.0
from __future__ import annotations
from pathlib import Path
from lxml import etree
from iso_lineage.build import NSMAP
def embed_lineage(metadata_path: Path, lineage: etree._Element, out_path: Path) -> None:
"""Insert LI_Lineage into DQ_DataQuality/lineage and write the record."""
parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(str(metadata_path), parser)
root = tree.getroot()
dq = root.find(
".//gmd:dataQualityInfo/gmd:DQ_DataQuality", namespaces=NSMAP
)
if dq is None:
raise ValueError("MD_Metadata has no DQ_DataQuality element to receive lineage.")
# Replace any existing lineage; a record carries exactly one.
for existing in dq.findall("gmd:lineage", namespaces=NSMAP):
dq.remove(existing)
wrapper = etree.SubElement(dq, f"{{{NSMAP['gmd']}}}lineage")
wrapper.append(lineage)
tree.write(
str(out_path), pretty_print=True, xml_declaration=True, encoding="UTF-8"
)
Verification Jump to heading
Confirm the generated record is both well-formed and schema-valid, and that the lineage actually landed where auditors look. Do not trust serialization success alone:
# python 3.10+, lxml >=5.0
from pathlib import Path
from lxml import etree
from iso_lineage.read import load_process_steps
from iso_lineage.build import build_lineage, NSMAP
from iso_lineage.validate import load_schema, validate_document
from iso_lineage.embed import embed_lineage
steps = load_process_steps("./_manifest", "run-2026-07-13")
lineage = build_lineage(steps, statement="Reprojected to EPSG:4326 and validated; see process steps.")
embed_lineage(Path("template.xml"), lineage, Path("out.xml"))
doc = etree.parse("out.xml").getroot()
schema = load_schema(Path("schema/gmd/metadataEntity.xsd"))
# 1. The whole record validates against the ISO 19139 XSD.
validate_document(doc, schema)
# 2. Exactly one LI_Lineage exists, under DQ_DataQuality.
found = doc.findall(".//gmd:DQ_DataQuality/gmd:lineage/gmd:LI_Lineage", namespaces=NSMAP)
assert len(found) == 1, f"expected one LI_Lineage, found {len(found)}"
# 3. Every process step carries a dateTime (an auditor's minimum).
for ps in found[0].findall(".//gmd:LI_ProcessStep", namespaces=NSMAP):
assert ps.find("gmd:dateTime/gco:DateTime", namespaces=NSMAP) is not None, "missing dateTime"
A healthy run logs ISO 19139 validation passed. and produces an out.xml a portal accepts. On the CLI, xmllint --schema schema/gmd/metadataEntity.xsd out.xml --noout prints out.xml validates, and xmllint --xpath 'count(//*[local-name()="LI_ProcessStep"])' out.xml returns the number of published stages.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
LineageValidationError naming gco:CharacterString |
A value was written as bare text instead of inside a gco: wrapper |
Route every string through _gco_char; ISO 19139 forbids untyped text nodes |
element processStep: This element is not expected |
statement emitted after processStep, violating content order |
Build statement first (Step 3 ordering); the ISO model is order-sensitive |
Namespace prefix gmd not defined on write |
nsmap not passed to the root element |
Create the root with nsmap=NSMAP; child elements inherit the declaration |
Empty <gmd:lineage/> in output |
DQ_DataQuality matched but LI_Lineage append targeted the wrong parent |
Verify the XPath resolves to DQ_DataQuality; embed under a fresh gmd:lineage wrapper |
dateTime rejected as invalid |
Non-ISO 8601 timestamp (missing T or offset) |
Emit event_time_utc.isoformat() from a tz-aware UTC datetime; never a naive string |
| XSD compiles but validation always fails offline | Schema imports reference remote URLs the resolver cannot fetch | Use a locally mirrored XSD bundle with a catalog so all imports resolve on disk |
The namespace and ordering failures are the two that dominate. ISO 19139 is a closed content model: every element has a fixed position and a required gco: type wrapper, so treat the XSD as the authority and validate before publish, every time. A record that skips validation will pass your tests and fail at the portal.
Related Jump to heading
- Lineage Manifest Generation — the parent stage that produces the Parquet rows rendered here
- Writing Append-Only Lineage Manifests to Parquet — how the source rows were written immutably before this reader consumes them
- Geospatial Schema Architecture & Standards Mapping — the standards-mapping discipline this ISO output belongs to
- Converting FGDC CSDGM to ISO 19115 Automatically — building the surrounding MD_Metadata record that this lineage embeds into
- Geospatial Compliance Reporting & Audit Trails — the practice area that turns pipeline events into regulator-ready evidence