Artifact workflow examples¶
These examples show how to keep ogcat responsible for cataloging, naming, locators, and operation rollback while domain code performs the scientific or network-specific work.
CAMS zip to processed boundary conditions¶
This workflow tracks a raw CAMS download, extracts its NetCDF members into a UUID-managed collection directory, records those members as one collection artifact, and writes a processed Zarr store with provenance linking the output back to the raw inputs.
The example uses the CAMS global inversion-optimised greenhouse gas fluxes and concentrations dataset from the Copernicus Atmosphere Data Store:
https://ads.atmosphere.copernicus.eu/datasets/cams-global-greenhouse-gas-inversion?tab=overview
The ADS download page configures the request. Those options are reflected in the downloaded file. Here the downloaded archive is:
~/Downloads/bab75005df9571750d518b0aacdedb35.zip
Its members are monthly CO2 concentration files for 2020-2022, with names like:
cams73_latest_co2_conc_surface_inst_202001.nc
cams73_latest_co2_conc_surface_inst_202002.nc
...
cams73_latest_co2_conc_surface_inst_202212.nc
The archive listing contains 36 monthly NetCDF files covering 2020-01 through 2022-12, with a total uncompressed size of 184,833,238,572 bytes.
In this naming convention, latest is the input version, co2 is the
species, conc means concentration, and surface_inst indicates surface in
situ observations. The 73 in cams73 is kept as part of the upstream input
version string because the workflow does not interpret it further.
Catalog spec¶
The raw download is an explicit reference. The extracted NetCDF collection and processed boundary-condition store are ogcat-owned outputs. The collection uses UUID primary storage because the member filenames already carry the useful human meaning; the processed Zarr store uses a human-readable schema template.
from pathlib import Path
from ogcat import Catalog, CatalogSpec, MetadataFieldDescription, RecordSchema
species_field = MetadataFieldDescription(
name="species",
description="Species represented by the data.",
required=True,
)
product_field = MetadataFieldDescription(
name="product",
description="Upstream product or data family.",
required=True,
)
title_field = MetadataFieldDescription(
name="title",
description="Human-readable data title.",
required=True,
)
domain_field = MetadataFieldDescription(
name="domain",
description="Processing or model domain.",
required=True,
)
bc_input_field = MetadataFieldDescription(
name="bc_input",
description="Boundary-condition input product.",
required=True,
)
catalog = Catalog.create(
Path("cams-bc-catalog"),
CatalogSpec(
catalog_name="cams_bc",
record_schemas={
"raw_download": RecordSchema(
description="Raw downloaded files, including archives.",
metadata_fields=[species_field, product_field, title_field],
),
"raw_netcdf_collection": RecordSchema(
description="Raw NetCDF collections extracted from archive artifacts.",
metadata_fields=[species_field, product_field, title_field],
),
"boundary_conditions": RecordSchema(
description="Processed boundary-condition stores.",
directory_template="boundary_conditions/{species}/{domain}",
filename_template="{bc_input}_{species}_{domain}.zarr",
metadata_fields=[species_field, domain_field, bc_input_field],
),
},
),
)
These schemas are not meant to be a complete artifact-type hierarchy. They
describe the metadata fields and naming templates that differ between raw
downloads, extracted raw collections, and processed boundary-condition outputs.
A simpler catalog could use one broad raw schema and distinguish zip files,
NetCDF files, and collections with ordinary metadata such as format or
content_kind.
1. Reference the raw zip¶
The first record documents the raw file in the download folder. ogcat records the locator and metadata but does not copy or inspect the archive.
from datetime import date
from pathlib import Path
CAMS_MEMBER_PATTERN = "cams73_latest_co2_conc_surface_inst_*.nc"
CAMS_EXPECTED_MEMBERS = [
f"cams73_latest_co2_conc_surface_inst_{year}{month:02d}.nc"
for year in range(2020, 2023)
for month in range(1, 13)
]
downloaded_zip = Path("~/Downloads/bab75005df9571750d518b0aacdedb35.zip").expanduser()
ads_dataset = (
"https://ads.atmosphere.copernicus.eu/datasets/"
"cams-global-greenhouse-gas-inversion?tab=overview"
)
raw_zip_record = catalog.add_reference(
downloaded_zip,
record_type="raw_download",
metadata={
"species": "co2",
"product": "cams",
"title": "CAMS global inversion-optimised greenhouse gas fluxes and concentrations",
"domain": "global",
"source_url": ads_dataset,
"downloaded_from": "Copernicus Atmosphere Data Store",
"downloaded_on": date(2026, 3, 18).isoformat(),
"comment": (
"Raw zip downloaded from the Copernicus Atmosphere Data Store; "
"contains global concentration fields for CO2."
),
"archive_name": downloaded_zip.name,
"archive_member_glob": CAMS_MEMBER_PATTERN,
"member_count": len(CAMS_EXPECTED_MEMBERS),
"time_coverage": "2020-01/2022-12",
"uncompressed_bytes": 184_833_238_572,
"bc_input": "cams",
"bc_input_version": "cams73_latest",
},
)
2. Extract the NetCDF collection into managed storage¶
Here the extraction is an ogcat-managed write. A writer validates the archive
members, extracts the NetCDF files into the planned UUID directory target, and
returns collection classification metadata. General add_artifact(...) writes
do not schedule template-link replicas, so the managed collection has only the
UUID primary location.
import fnmatch
import shutil
import zipfile
from ogcat import (
OperationSource,
collection_classification_metadata,
path_source,
source_writer,
)
def write_cams_collection_archive(
source: OperationSource,
target_dir: Path,
) -> dict[str, object]:
"""Extract the expected CAMS NetCDF members into a managed directory."""
if source.path is None:
raise ValueError("CAMS collection writer requires a local zip source path.")
with zipfile.ZipFile(source.path) as archive:
members: dict[str, zipfile.ZipInfo] = {}
for member in archive.infolist():
member_name = Path(member.filename).name
if member.is_dir() or not fnmatch.fnmatch(member_name, CAMS_MEMBER_PATTERN):
continue
if member_name in members:
raise ValueError(f"Duplicate CAMS archive member basename: {member_name}")
members[member_name] = member
expected_names = sorted(CAMS_EXPECTED_MEMBERS)
matched_names = sorted(members)
if matched_names != expected_names:
missing = sorted(set(expected_names) - set(matched_names))
unexpected = sorted(set(matched_names) - set(expected_names))
raise ValueError(f"Unexpected CAMS members: missing={missing}, unexpected={unexpected}.")
for name in expected_names:
member = members[name]
destination = target_dir / name
with archive.open(member) as source_file, destination.open("wb") as target_file:
shutil.copyfileobj(source_file, target_file)
return {
"extracted_file_count": len(expected_names),
"extracted_names": expected_names,
"classification": collection_classification_metadata(
collection_pattern=CAMS_MEMBER_PATTERN,
member_format="netcdf",
member_suffixes=[".nc"],
reader_hint="xarray.open_mfdataset",
),
}
collection_metadata = {
"species": "co2",
"product": "cams",
"title": "CAMS CO2 concentration NetCDF collection",
"domain": "global",
"source_record_id": raw_zip_record.id,
"source_url": ads_dataset,
"archive_name": downloaded_zip.name,
"archive_member_glob": CAMS_MEMBER_PATTERN,
"member_count": len(CAMS_EXPECTED_MEMBERS),
"time_coverage": "2020-01/2022-12",
"bc_input": "cams",
"bc_input_version": "cams73_latest",
}
collection_plan = catalog.plan_artifact_storage(
record_type="raw_netcdf_collection",
metadata=collection_metadata,
target_kind="directory",
write_mode="write",
primary_location="uuid",
)
collection_record = catalog.add_artifact(
record_type="raw_netcdf_collection",
storage_plan=collection_plan,
metadata=collection_metadata,
source=path_source(downloaded_zip, kind="zip_file"),
artifact_writer=source_writer(
write_cams_collection_archive,
target_kind="directory",
source_kind="zip_file",
),
)
The resulting record points at a managed directory under data/objects. It is
still one logical dataset, not 36 separate file records. The explicit
collection_pattern and reader_hint in the classification metadata
document how downstream code should read the members. This uses the lower-level
artifact writer path because managed collections are not yet a first-class
add_collection(...) write target. When that API grows, archive-to-collection
workflows should be expressible without manually attaching collection
classification metadata.
3. Process the collection into a Zarr artifact¶
The processing writer is domain code. A small helper uses the collection’s
reader and glob hints to open the matching NetCDF members with
xarray.open_mfdataset() and pass the opened dataset as the operation-source
payload. A function writer consumes that dataset, calls a project-specific
create_cams_bc() function, and writes the returned dataset to a single
.zarr store.
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import xarray as xr
from ogcat import CatalogRecord, OperationSource, memory_source, source_writer
def create_cams_bc(ds: xr.Dataset, *, species: str, domain: str) -> xr.Dataset:
"""Create boundary-condition data from CAMS concentration fields."""
...
@contextmanager
def xarray_collection_source(record: CatalogRecord, **metadata: object) -> Iterator[OperationSource]:
"""Open a collection record as an xarray-backed operation source."""
collection_root = record.path()
if collection_root is None:
raise ValueError("Expected collection record to have a local path.")
classification = record.derived_metadata.get("classification", {})
if classification.get("reader_hint") != "xarray.open_mfdataset":
raise ValueError("Collection is not marked for xarray.open_mfdataset.")
collection_pattern = str(classification["collection_pattern"])
input_paths = sorted(collection_root.glob(collection_pattern))
expected_count = record.user_metadata.get("member_count")
if expected_count is not None and len(input_paths) != int(expected_count):
raise ValueError(f"Expected {expected_count} members, found {len(input_paths)}.")
source_metadata = {
"collection_record_id": record.id,
"collection_pattern": collection_pattern,
"input_file_count": len(input_paths),
"input_paths": [str(path) for path in input_paths],
"reader_hint": classification["reader_hint"],
}
source_metadata.update(metadata)
with xr.open_mfdataset(input_paths) as ds:
yield memory_source(
ds,
kind="xarray_netcdf_collection",
metadata=source_metadata,
)
def write_cams_boundary_conditions(source: OperationSource, target: Path) -> dict[str, object]:
"""Write processed CAMS boundary conditions from an opened xarray dataset."""
if not isinstance(source.payload, xr.Dataset):
raise TypeError("Expected source.payload to be an xarray Dataset.")
species = str(source.metadata.get("species", "co2"))
processing_domain = str(source.metadata["processing_domain"])
processed = create_cams_bc(source.payload, species=species, domain=processing_domain)
processed.to_zarr(target, mode="w")
return {
"input_record_id": source.metadata["collection_record_id"],
"raw_zip_record_id": source.metadata["raw_zip_record_id"],
"input_file_count": source.metadata["input_file_count"],
"input_collection_pattern": source.metadata["collection_pattern"],
"input_reader": source.metadata["reader_hint"],
"species": species,
"bc_input": "cams",
"bc_input_version": "cams73_latest",
"domain": processing_domain.lower(),
"reader_hint": "xarray.open_zarr",
}
Plan and write the processed artifact:
processed_metadata = {
"species": "co2",
"domain": "europe",
"bc_input": "cams",
"bc_input_version": "cams73_latest",
"title": "CAMS CO2 boundary conditions for EUROPE",
"source_record_id": collection_record.id,
"raw_zip_record_id": raw_zip_record.id,
"source_url": ads_dataset,
"processing_domain": "EUROPE",
"operation": "create_cams_bc",
"opened_with": "xarray.open_mfdataset",
}
processed_plan = catalog.plan_artifact_storage(
record_type="boundary_conditions",
target_kind="directory",
write_mode="write",
metadata=processed_metadata,
primary_location="template",
)
with xarray_collection_source(
collection_record,
raw_zip_record_id=raw_zip_record.id,
species="co2",
processing_domain="EUROPE",
) as source:
processed_record = catalog.add_artifact(
record_type="boundary_conditions",
storage_plan=processed_plan,
metadata=processed_metadata,
source=source,
artifact_writer=source_writer(
write_cams_boundary_conditions,
target_kind="directory",
source_kind="xarray_netcdf_collection",
),
)
The resulting record is a generic directory artifact whose locator points to the
schema-rendered .zarr store at
boundary_conditions/co2/europe/cams_co2_europe.zarr. The fact that it can be
opened with xarray, and the provenance needed to rebuild it, are ordinary
metadata rather than special ogcat core concepts.
Variant: build the fsspec input at processing time¶
The persistent collection is useful when the extracted NetCDF files are shared
inputs. If the extraction is only an implementation detail, run this variant
instead of the collection-backed processing step above. Keep the raw zip record
as the source of truth and build the fsspec URL path during the processing
operation. The hook below prepares a simplecache plus zip URL path only
for this operation; the writer then receives local cached member files from
fsspec and writes the same managed Zarr output.
simplecache::zip://cams73_latest_co2_conc_surface_inst_*.nc::file:///.../bab75005df9571750d518b0aacdedb35.zip
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
import fsspec
import xarray as xr
from ogcat import ArtifactLocator, OperationContext, OperationSource, memory_source
def cams_zip_member_urlpath(locator: ArtifactLocator, *, member_glob: str) -> str:
"""Build a chained fsspec URL pattern for CAMS NetCDF members."""
if locator.kind == "path":
zip_url = Path(locator.value).as_uri()
elif locator.kind == "urlpath":
zip_url = locator.value
else:
raise ValueError(f"Cannot build a fsspec zip chain from {locator.kind!r}")
return f"simplecache::zip://{member_glob}::{zip_url}"
@dataclass(frozen=True)
class CamsZipMemberUrlpathHook:
"""Prepare a fsspec zip-member URL path for CAMS processing."""
def before_validate_metadata(self, context: OperationContext) -> None:
if context.record_type != "boundary_conditions":
return
if context.source.kind != "cams_zip_member_urlpath":
return
raw_locator = ArtifactLocator.from_dict(context.source.metadata["raw_locator"])
member_glob = str(context.source.metadata["archive_member_glob"])
context.source.metadata["input_urlpath"] = cams_zip_member_urlpath(
raw_locator,
member_glob=member_glob,
)
@dataclass(frozen=True)
class CamsZipChainToZarrWriter:
"""Create a Zarr store from a prepared fsspec zip-member URL path."""
def write(
self,
context: OperationContext,
source: OperationSource,
target: ArtifactLocator,
) -> None:
target_path = target.as_path()
if target_path is None:
raise ValueError("CAMS writer requires a local Zarr target.")
if target_path.exists():
raise FileExistsError(target_path)
input_urlpath = str(source.metadata["input_urlpath"])
species = str(source.metadata.get("species", "co2"))
processing_domain = str(source.metadata["processing_domain"])
target_path.parent.mkdir(parents=True, exist_ok=True)
context.rollback(
lambda path=target_path: shutil.rmtree(path, ignore_errors=True),
description=f"remove processed Zarr store {target_path}",
)
with tempfile.TemporaryDirectory(prefix="ogcat-cams-cache-") as cache_dir:
local_members = fsspec.open_local(
input_urlpath,
mode="rb",
simplecache={"cache_storage": cache_dir},
)
input_paths = [Path(path) for path in local_members]
expected_count = int(source.metadata["member_count"])
if len(input_paths) != expected_count:
raise ValueError(f"Expected {expected_count} members, found {len(input_paths)}.")
with xr.open_mfdataset(input_paths) as ds:
processed = create_cams_bc(ds, species=species, domain=processing_domain)
processed.to_zarr(target_path, mode="w")
context.derived_metadata.update(
{
"raw_zip_record_id": source.metadata["raw_zip_record_id"],
"input_urlpath": input_urlpath,
"archive_member_glob": source.metadata["archive_member_glob"],
"input_file_count": len(input_paths),
"temporary_cache": "fsspec simplecache inside a TemporaryDirectory",
"species": species,
"bc_input": "cams",
"bc_input_version": "cams73_latest",
"domain": processing_domain.lower(),
"reader_hint": "xarray.open_zarr",
}
)
Then plan the same kind of boundary-condition artifact, but pass the raw zip record’s locator and archive hints as operation-source metadata:
catalog.hook_manager.register(CamsZipMemberUrlpathHook())
fsspec_processed_metadata = {
"species": "co2",
"domain": "europe",
"bc_input": "cams",
"bc_input_version": "cams73_latest",
"title": "CAMS CO2 boundary conditions for EUROPE",
"source_record_id": raw_zip_record.id,
"source_url": ads_dataset,
"processing_domain": "EUROPE",
"operation": "create_cams_bc",
"opened_with": "xarray.open_mfdataset",
"zip_access": "fsspec simplecache over zip filesystem",
}
fsspec_processed_plan = catalog.plan_artifact_storage(
record_type="boundary_conditions",
target_kind="directory",
write_mode="write",
metadata=fsspec_processed_metadata,
primary_location="template",
)
fsspec_processed_record = catalog.add_artifact(
record_type="boundary_conditions",
storage_plan=fsspec_processed_plan,
metadata=fsspec_processed_metadata,
source=memory_source(
None,
kind="cams_zip_member_urlpath",
metadata={
"raw_locator": raw_zip_record.locator.to_dict(),
"raw_zip_record_id": raw_zip_record.id,
"species": "co2",
"processing_domain": "EUROPE",
"archive_member_glob": raw_zip_record.user_metadata["archive_member_glob"],
"member_count": raw_zip_record.user_metadata["member_count"],
},
),
artifact_writer=CamsZipChainToZarrWriter(),
)
This variant demonstrates fsspec without making the raw zip record depend on a specific processing strategy. The tradeoff is that the NetCDF member collection is not independently searchable in the catalog.
URI reference followed by a download writer¶
This pattern records an external URI first, then creates a managed local copy
with a custom writer. It is useful when downloads are performed by requests,
curl, an authenticated client, or a project-specific API.
The catalog can plan a template-primary target so the downloaded source filename does not need to be meaningful, while the managed output still lands at a human-readable path:
from pathlib import Path
from ogcat import ArtifactLocator, Catalog, CatalogSpec, RecordSchema
catalog = Catalog.create(
Path("download-catalog"),
CatalogSpec(
catalog_name="downloads",
default_schema=RecordSchema(
directory_template="downloads/{year_added}",
filename_template="{title_slug|original_stem}{original_suffix}",
),
),
)
Record the external reference:
from datetime import date
source_record = catalog.add_artifact(
record_type="download_reference",
locator=ArtifactLocator(kind="uri", value="https://example.org/data/example.nc"),
storage_mode="external",
metadata={
"species": "co2",
"product": "example",
"title": "Example downloadable CO2 data",
"download_page": "https://example.org/data",
"selected_options": {"format": "netcdf", "variable": "co2"},
"reference_recorded_on": date.today().isoformat(),
},
)
Write a managed copy with a small requests-based writer:
import requests
from ogcat import ArtifactLocator, OperationContext, OperationSource, memory_source
class RequestsDownloadWriter:
"""Download a URI to the planned local target."""
def write(self, context: OperationContext, source: OperationSource, target: ArtifactLocator) -> None:
target_path = target.as_path()
if target_path is None:
raise ValueError("Download writer requires a local path target.")
if target_path.exists():
raise FileExistsError(target_path)
url = str(source.metadata["url"])
target_path.parent.mkdir(parents=True, exist_ok=True)
context.rollback(lambda path=target_path: path.unlink(missing_ok=True), description="remove download")
with requests.get(url, timeout=60, stream=True) as response:
response.raise_for_status()
with target_path.open("wb") as target_file:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
target_file.write(chunk)
context.derived_metadata.update(
{
"source_record_id": source.metadata["source_record_id"],
"downloaded_from": url,
"downloaded_on": source.metadata["downloaded_on"],
"byte_count": target_path.stat().st_size,
}
)
download_metadata = {
"species": "co2",
"product": "example",
"title": "Example downloaded CO2 data",
"source_record_id": source_record.id,
}
download_plan = catalog.plan_artifact_storage(
Path("example.nc"),
write_mode="write",
metadata=download_metadata,
primary_location="template",
)
download_record = catalog.add_artifact(
record_type="downloaded_file",
storage_plan=download_plan,
metadata=download_metadata,
source=memory_source(
None,
kind="download_uri",
metadata={
"url": source_record.locator.value,
"source_record_id": source_record.id,
"downloaded_on": date.today().isoformat(),
},
),
artifact_writer=RequestsDownloadWriter(),
)
This keeps the distinction clear: the URI record says where the data came from, and the managed file record says what was downloaded, where it was stored, and which source record it came from.