CalCOFI CalCOFI workflows

Publish every biological dataset to OBIS

One Darwin Core Archive per dataset, generated from the core and the registries

Author

CalCOFI

Published

2026-09-06

1 Overview

Build one Darwin Core Archive per biological dataset in the frozen release — event.csv (Event core) + occurrence.csv + extendedMeasurementOrFact.csv + meta.xml + the release’s own eml/{dataset_key}.xml — validate it, and write it to data/darwincore/{dataset_key}/{dataset_key}_{version}.zip with a manifest.

This notebook writes files only. The upload to the OBIS-USA IPT is a deliberate manual act with Ben’s login, gated on a content change (Decision 10; docs/portals.qmd § OBIS has the steps). Nothing here talks to a portal except the read-only duplicate check below.

1.1 Why it is generic now

publish_ichthyo_to-obis.qmd was the last per-dataset publisher. It read the swfsc_ichthyo source tables (ichthyo, net, tow, site, species, lookup) and hand-built the Event / Occurrence / eMoF triple, which is why nine other biological datasets had no OBIS route at all — and why, since the core consolidation retired those tables from the release, it can no longer run.

Everything it hand-built now exists generically, and the vocabulary ids it was missing live in the registries the release already publishes:

Darwin Core from the core vocabulary
Event core eventID · parentEventID · eventType · eventDate · decimalLatitude/Longitude · minimum/maximumDepthInMeters · locationID · samplingProtocol · sampleSizeValue/Unit · geodeticDatum · datasetID sample.sample_key · sample.parent_sample_key (the row’s cruise_key for a root) · sample.sample_type · sample.datetime · sample.latitude/longitude · sample.depth_min_m/depth_max_m · sample.site_key · sample.tow_type · sample_measurement.volume_sampled · the release CRS · dataset_key gear.csv dwc_samplingProtocol (+ NERC L22)
Occurrence occurrenceID · eventID · basisOfRecord · occurrenceStatus · scientificName · scientificNameID · taxonID · taxonRank · kingdomfamily · vernacularName · lifeStage · individualCount · organismQuantity + organismQuantityType · occurrenceRemarks md5 of obs_bio’s natural grain · obs_bio.sample_key · HumanObservation · obs_bio.value · taxon.scientific_name · WoRMS LSID of taxon.worms_id · obs_bio.taxon_key · taxon.rank · taxon lineage · taxon.common_name · obs_bio.life_stage · obs_bio.value where the unit is a count · density_per_10m2 / density_per_1000m3 life_stage.csv dwc_lifeStage (+ NERC S11)
eMoF measurementType · measurementTypeID · measurementValue · measurementUnit · measurementUnitID · measurementRemarks sample_measurement (event grain) · obs_attribute (occurrence grain) · obs_env on the dataset’s own events measurement_type.csv nerc_p01 / units_nerc_p06empty where no exact concept exists, never invented
meta.xml generated from the term map (calcofi4db::dwc_term_map())
eml.xml the release’s eml/{dataset_key}.xml (build_eml(), D-8)

The logic lives in calcofi4db::dwc_*() (R/dwc.R), not in this notebook, so devtools::test() asserts the exact DwC rows against a synthetic core and this file cannot drift from what the tests pin.

1.2 occurrenceStatus is emitted honestly

ImportantAn absence is a claim about a protocol, not about a table

calcofi4db::dwc_absence_rule() measures which rule each dataset falls under:

  • zeros_recorded — the dataset has zero-valued obs_bio rows, so a sample examined and found empty of a taxon is already in the release. Those rows become occurrenceStatus = "absent", and nothing is derived.
  • positive_only — the dataset has no zero rows: a surveyed-empty sample simply has no row. Every row it does have is present, and an absence can only be derived, from sample_root minus the positives — which is true only if the protocol sorted every sample for the dataset’s whole vocabulary.

That last claim is about the protocol, not about the data, so deriving absences is never the default. For swfsc_ichthyo it would be plainly false: the ichthyoplankton protocol identifies each specimen to the lowest taxon possible, so 963 observed taxa × 61,104 sorted stations is 58 M absences nobody ever asserted. dwc_occurrence() refuses to emit them without absences = "sample_root" and a max_absences the caller raised on purpose.

The two positive-only datasets where the claim may genuinely hold — cce-lter_euphausiids (BTEDB stages 37 species in every sample) and sio_mesopelagic-fish (90 taxa, 102 tows) — are questions for their providers, not something this notebook decides. Until a provider says yes, they publish presence-only, which is what OBIS assumes of an archive with no absent rows.

2 Setup

Code
librarian::shelf(DBI, duckdb, dplyr, glue, jsonlite, knitr, readr, tibble,
                 here, curl, quiet = TRUE)
here <- here::here
devtools::load_all(here::here("../calcofi4db"))

# a STAGING run (CALCOFI_RELEASE_PREFIX=ducklake-staging/releases) reads the
# staging release and writes under data/darwincore-staging/, so it can never be
# mistaken for, or overwrite, what was published from the promoted one
RELEASE_PREFIX <- Sys.getenv("CALCOFI_RELEASE_PREFIX", "ducklake/releases")
STAGING        <- grepl("staging", RELEASE_PREFIX, fixed = TRUE)
BASE_HTTPS     <- "https://storage.googleapis.com/calcofi-db"
RELEASES_URL   <- glue("{BASE_HTTPS}/{RELEASE_PREFIX}")

RELEASE <- Sys.getenv("CALCOFI_RELEASE_VERSION", "")
if (!nzchar(RELEASE))
  RELEASE <- trimws(readLines(glue("{RELEASES_URL}/latest.txt"), warn = FALSE)[1])

OUT_DIR <- here(if (STAGING) "data/darwincore-staging" else "data/darwincore")
dir.create(OUT_DIR, recursive = TRUE, showWarnings = FALSE)

# comma-separated dataset_keys to restrict a run while iterating; empty = all
ONLY <- Filter(nzchar, trimws(strsplit(Sys.getenv("CALCOFI_DATASETS", ""), ",")[[1]]))
# the duplicate check asks api.obis.org; off-line it is skipped, never faked
NET  <- !identical(Sys.getenv("CALCOFI_OFFLINE"), "true")

cat(glue("release  : {RELEASE} (prefix {RELEASE_PREFIX})"), "\n")
release  : v2026.09.06 (prefix ducklake/releases) 
Code
cat(glue("output   : {OUT_DIR}"), "\n")
output   : /Users/bbest/Github/CalCOFI/workflows/data/darwincore 
Code
cat(glue("network  : {NET} (set CALCOFI_OFFLINE=true to skip the OBIS duplicate check)"), "\n")
network  : TRUE (set CALCOFI_OFFLINE=true to skip the OBIS duplicate check) 
Code
if (length(ONLY)) cat(glue("restricted to: {paste(ONLY, collapse = ', ')}"), "\n")
Code
# the release catalog resolves every table to its content-addressed objects —
# never concatenate releases/{v}/parquet/… by hand (CLAUDE.md § content-addressed)
rel_catalog <- jsonlite::fromJSON(glue("{RELEASES_URL}/{RELEASE}/catalog.json"),
                                  simplifyVector = FALSE)
rel_urls <- function(table) {
  src <- calcofi4r::cc_release_sources(rel_catalog, table)
  sf <- src$single_file
  # `single_file` is NA for a table with no whole-table twin, and as.character(NA)
  # is the STRING "NA" — which nzchar() happily accepts and read_parquet() then
  # resolves to a file called "NA". Test is.na() first, always.
  if (!is.null(sf) && length(sf) == 1 && !is.na(sf) && nzchar(as.character(sf)))
    return(as.character(sf))
  as.character(src$urls)
}
url_list <- function(u) if (length(u) == 1) glue("'{u}'") else
  glue("[{paste0(\"'\", u, \"'\", collapse = ', ')}]")
read_pq <- function(table, hive = FALSE)
  glue("read_parquet({url_list(rel_urls(table))}",
       "{if (hive) ', hive_partitioning = true' else ''}, union_by_name = true)")

con <- dbConnect(duckdb())
for (s in c("INSTALL httpfs", "LOAD httpfs", "SET memory_limit='8GB'",
            "SET enable_progress_bar=false")) try(dbExecute(con, s), silent = TRUE)

# `sample` is read WITHOUT geom: the exporter needs lat/lon numerics, and a
# CRS-tagged geometry column would pull in the spatial extension for nothing.
t0 <- Sys.time()
dbExecute(con, glue("
  CREATE TABLE sample AS
    SELECT sample_key, sample_type, parent_sample_key, root_sample_key, dataset_key,
           grid_key, site_key, cruise_key, order_occ, latitude, longitude, datetime,
           depth_min_m, depth_max_m, tow_type
    FROM {read_pq('sample')};
  CREATE TABLE obs_bio           AS SELECT * FROM {read_pq('obs_bio')};
  CREATE TABLE obs_attribute     AS SELECT * FROM {read_pq('obs_attribute')};
  CREATE TABLE sample_measurement AS SELECT * FROM {read_pq('sample_measurement')};
  CREATE TABLE taxon             AS SELECT * FROM {read_pq('taxon')};
  CREATE TABLE sample_root       AS SELECT * FROM {read_pq('sample_root')};
  CREATE TABLE cruise            AS SELECT * FROM {read_pq('cruise')};
  CREATE TABLE dataset           AS SELECT * FROM {read_pq('dataset')};"))
[1] 16
Code
cat(glue("materialized the core in {round(difftime(Sys.time(), t0, units = 'secs'))}s"), "\n")
materialized the core in 18s 
Code
q <- function(sql, ...) dbGetQuery(con, glue(sql, ..., .envir = parent.frame()))
Code
# gear.csv + life_stage.csv + measurement_type.csv — the single sources of truth
# for samplingProtocol, lifeStage and the P01/P06 ids. An id is filled ONLY on an
# exact match; an empty cell means "no concept says exactly this".
reg <- dwc_registries(here("metadata"))
cat(glue("gear            : {nrow(reg$gear)} codes, ",
         "{sum(!is.na(reg$gear$nerc_l22))} with an L22 device id"), "\n")
gear            : 11 codes, 4 with an L22 device id 
Code
cat(glue("life stage      : {nrow(reg$life_stage)} values, ",
         "{sum(!is.na(reg$life_stage$nerc_s11))} with an S11 concept"), "\n")
life stage      : 23 values, 10 with an S11 concept 
Code
cat(glue("measurement type: {nrow(reg$measurement_type)} types, ",
         "{sum(!is.na(reg$measurement_type$nerc_p01))} with a P01, ",
         "{sum(!is.na(reg$measurement_type$units_nerc_p06))} with a P06 unit"), "\n")
measurement type: 200 types, 115 with a P01, 174 with a P06 unit 
Code
# the release's own EML, one document per dataset (D-8). A staging release may not
# carry eml/ yet; the archive is still built and the missing document is reported,
# never substituted with strings typed here.
EML_DIR <- file.path(OUT_DIR, "_eml", RELEASE)
dir.create(EML_DIR, recursive = TRUE, showWarnings = FALSE)
eml_path_of <- function(k) {
  local <- file.path(EML_DIR, paste0(k, ".xml"))
  if (file.exists(local)) return(local)
  url <- glue("{RELEASES_URL}/{RELEASE}/eml/{k}.xml")
  ok <- tryCatch({ curl::curl_download(url, local, quiet = TRUE); TRUE },
                 error = function(e) FALSE)
  if (ok && file.exists(local)) local else NA_character_
}

3 The plan — every dataset, before anything is written

Code
# Decision 21 is MEASURED, not listed: a dataset publishes when it has obs_bio rows
# and at least one observed taxon carries a WoRMS id. cce-lter_picoplankton-bacteria
# (flow-cytometry groups, environmental realm) and sio_pic-zooplankton (no taxa) have
# no obs_bio rows at all, so they fall out by construction rather than by exclusion.
cand_all <- dwc_datasets(con)
cand <- if (length(ONLY)) cand_all[cand_all$dataset_key %in% ONLY, , drop = FALSE] else cand_all

names_of <- q("SELECT provider || '_' || dataset AS dataset_key, dataset_name FROM dataset")
plan <- cand |>
  left_join(names_of, by = "dataset_key") |>
  mutate(
    n_events = vapply(dataset_key, function(k)
      q("SELECT COUNT(*) n FROM sample WHERE dataset_key = '{k}'")$n, numeric(1)),
    gear_codes = vapply(dataset_key, function(k)
      nrow(dataset_gear(reg$gear, k)), numeric(1)),
    eml = vapply(dataset_key, function(k) !is.na(eml_path_of(k)), logical(1))) |>
  select(dataset_key, dataset_name, n_events, n_obs, n_taxa, n_worms, n_no_worms,
         n_no_taxon, absence_rule, gear_codes, eml)

write_csv(plan, file.path(OUT_DIR, "publish_plan.csv"), na = "")
kable(plan, caption = glue("Darwin Core plan for {RELEASE} — {nrow(plan)} datasets"))
Darwin Core plan for v2026.09.06 — 10 datasets
dataset_key dataset_name n_events n_obs n_taxa n_worms n_no_worms n_no_taxon absence_rule gear_codes eml
calcofi_phyllosoma CalCOFI Lobster Phyllosoma 1859 1859 1 1 0 0 zeros_recorded 0 TRUE
calcofi_phytoplankton CalCOFI Phytoplankton (Venrick) 409 159804 309 299 10 0 zeros_recorded 0 TRUE
cce-lter_euphausiids CCE-LTER Euphausiid Abundance 7482 100505 37 37 0 0 positive_only 0 TRUE
cce-lter_zoodb ZooDB Holoplankton Community 506 30948 33 33 0 0 zeros_recorded 0 TRUE
cce-lter_zooscan ZooScan PRPOOS Zooplankton 1483 126692 23 19 4 0 zeros_recorded 0 TRUE
cdfw_dungeness-crab CDFW Dungeness Crab Megalopae 526 1456 3 3 0 0 zeros_recorded 1 TRUE
farallon_bird-mammal CalCOFI Bird & Mammal Census 64421 69661 131 129 2 762 positive_only 0 TRUE
sio_mesopelagic-fish UCSD SIO Mesopelagic Fish 102 1393 87 87 0 1 positive_only 0 TRUE
swfsc_cufes CalCOFI Underway CUFES Fish Eggs 49572 284097 6 6 0 0 zeros_recorded 0 TRUE
swfsc_ichthyo SWFSC Ichthyoplankton 213122 482250 963 963 0 0 positive_only 10 TRUE
Code
# eMoF's third grain is "the env rows sitting on THIS dataset's own events". obs_env
# is 25 M rows across 84 objects hive-partitioned by **measurement_type**, not by
# dataset, so which datasets it covers cannot be read off the object names — one
# projection scan of `dataset_key` answers it in ~13 s, and only the candidates' rows
# are then materialized. Today that is zero rows (every obs_env dataset is
# environmental: bottle, ctd-cast, dic, mets, picoplankton-bacteria), which the count
# below STATES rather than assumes — a bio dataset that starts recording its own CTD
# would light up here with no code change.
env_by_ds <- q("SELECT dataset_key, COUNT(*) AS n FROM {read_pq('obs_env', hive = TRUE)}
                GROUP BY 1 ORDER BY 1")
env_cand <- intersect(env_by_ds$dataset_key, cand$dataset_key)
cat(glue("obs_env covers: {paste(env_by_ds$dataset_key, collapse = ', ')}"), "\n")
obs_env covers: calcofi_bottle, calcofi_ctd-cast, calcofi_dic, calcofi_mets, cce-lter_picoplankton-bacteria 
Code
if (length(env_cand)) {
  keys <- paste0("'", env_cand, "'", collapse = ", ")
  dbExecute(con, glue("CREATE TABLE obs_env AS SELECT * FROM
                       {read_pq('obs_env', hive = TRUE)} WHERE dataset_key IN ({keys})"))
  cat(glue("\nenv rows on a candidate's own events: ",
           "{q('SELECT COUNT(*) n FROM obs_env')$n} ({paste(env_cand, collapse = ', ')})"), "\n")
} else {
  cat("\nno candidate dataset has obs_env rows — the eMoF env grain is empty\n")
}

no candidate dataset has obs_env rows — the eMoF env grain is empty
Code
bio_all <- q("SELECT DISTINCT dataset_key FROM obs_bio ORDER BY 1")$dataset_key
no_worms <- setdiff(bio_all, cand_all$dataset_key)
if (length(no_worms))
  cat(glue("biological in the core but no taxon resolves to WoRMS ",
           "(no IPT resource, Decision 21): {paste(no_worms, collapse = ', ')}"), "\n")
env_only <- setdiff(q("SELECT DISTINCT dataset_key FROM sample")$dataset_key, bio_all)
cat(glue("\nno obs_bio rows at all (environmental or taxon-free, nothing to publish ",
         "to OBIS): {paste(sort(env_only), collapse = ', ')}"), "\n")
no obs_bio rows at all (environmental or taxon-free, nothing to publish to OBIS): calcofi_bottle, calcofi_ctd-cast, calcofi_dic, calcofi_mets, cce-lter_picoplankton-bacteria, sio_pic-zooplankton 
Code
if (any(!plan$eml))
  cat(glue("\nno eml/{{dataset_key}}.xml in {RELEASE} for: ",
           "{paste(plan$dataset_key[!plan$eml], collapse = ', ')} — ",
           "build it with build_eml_catalog() + write_eml_files() ",
           "(release_database.qmd s 3c) before an upload"), "\n")

4 Duplicates first — what OBIS already holds for these sources

WarningA provider’s own record is never duplicated

Before any upload, the OBIS records that already cover the same source are listed and resolved with their owners (Decision 21): a historical CalCOFI record is retired or cross-referenced with its owner’s agreement, and a dataset whose authority is CCE-LTER or NOAA is never republished by CalCOFI without that provider’s yes. This table is for Ben and the providers; it blocks nothing here, because nothing here uploads.

Code
# the curated rows first: metadata/distribution.csv is the record of what CalCOFI
# already published where, and it is never guessed from a search
dist <- read_distribution_registry(here("metadata/distribution.csv"))
known <- dist |>
  filter(portal %in% c("obis", "ipt")) |>
  select(dataset_key, portal, id, url, title, status, notes)
if (nrow(known)) kable(known, caption = "curated OBIS / IPT distributions (distribution.csv)")
curated OBIS / IPT distributions (distribution.csv)
dataset_key portal id url title status notes
swfsc_ichthyo obis 0e223f55-c826-4513-ae9a-b04cbf2e189c https://obis.org/dataset/0e223f55-c826-4513-ae9a-b04cbf2e189c CalCOFI Fish Larvae & Egg Tows current published 2026-03-27 through the OBIS-USA IPT from publish_ichthyo_to-obis.qmd (DwC-A: 77,188 events · 463,655 occurrences · 610,816 eMoF); featured by OBIS 2026-04-15. The OBIS text search does not match ‘CalCOFI’ — address it by this id, never by search.
swfsc_ichthyo ipt calcofi_ichthyo https://ipt-obis.gbif.us/resource?r=calcofi_ichthyo CalCOFI Fish Larvae & Egg Tows (OBIS-USA IPT resource) current the IPT resource behind the OBIS dataset; Ben holds the login; upload is manual, gated on a content change (plan D-8)
Code
obis_search <- function(terms) {
  # OBIS's dataset search, read-only. The CalCOFI record is NOT findable by the
  # word "CalCOFI" (measured, 2026-09-05 — that is why distribution.csv carries
  # its id), so institution and taxon words are what is asked here.
  out <- lapply(terms, function(tm) {
    u <- glue("https://api.obis.org/v3/dataset?q={utils::URLencode(tm, reserved = TRUE)}&size=25")
    j <- tryCatch(jsonlite::fromJSON(u, simplifyVector = FALSE), error = function(e) NULL)
    r <- if (is.null(j)) list() else j$results
    if (!length(r)) return(NULL)
    tibble(
      term    = tm,
      obis_id = vapply(r, function(x) x$id %||% NA_character_, ""),
      title   = vapply(r, function(x) x$title %||% NA_character_, ""),
      owner   = vapply(r, function(x)
        paste(unlist(lapply(x$institutes %||% list(), function(i) i$name)), collapse = "; "), ""),
      records = vapply(r, function(x) as.numeric(x$records %||% NA), numeric(1)),
      published = vapply(r, function(x) as.character(x$published %||% NA), ""))
  })
  bind_rows(out)
}

if (NET) {
  hits <- obis_search(c("CalCOFI",
                        "California Cooperative Oceanic Fisheries Investigations",
                        "Southwest Fisheries Science Center",
                        "Scripps Institution of Oceanography",
                        "California Current Ecosystem LTER"))
  # OBIS's `q` is a loose full-text OR, so "California Current euphausiid" returns
  # Happywhale killer whales. The candidate list is therefore narrowed to records
  # whose OWN title or institute names one of the organizations behind these
  # datasets — a filter on what came back, never a claim that nothing else exists.
  who <- "calcofi|cooperative oceanic|southwest fisheries|scripps|california current ecosystem|cce.?lter|farallon|dungeness"
  hits <- hits |>
    distinct(obis_id, .keep_all = TRUE) |>
    filter(grepl(who, tolower(paste(title, owner)))) |>
    arrange(desc(records))
  if (nrow(hits)) {
    kable(hits |> select(-term), caption = paste(
      "OBIS datasets whose title or institute names one of these datasets' sources —",
      "resolve each with its owner before an upload (a provider's own record is never",
      "republished). Not exhaustive: OBIS's text search does not match every record,",
      "which is why metadata/distribution.csv holds the ids we know."))
  } else cat("no OBIS dataset named one of these sources in its title or institute\n")
} else {
  cat("CALCOFI_OFFLINE=true — the OBIS duplicate check was skipped (not 'no duplicates')\n")
}
OBIS datasets whose title or institute names one of these datasets’ sources — resolve each with its owner before an upload (a provider’s own record is never republished). Not exhaustive: OBIS’s text search does not match every record, which is why metadata/distribution.csv holds the ids we know.
obis_id title owner records published
d054afae-bb0c-484e-8658-d92b41ff9d7d CalCOFI and NMFS Seabird and Marine Mammal Observation Data, 1987-2006 Duke University 70637 2025-10-07T22:48:27.000Z
539de636-d02c-4f17-9d85-37473e00f5c7 Investigation of Dredged Sediment Deposition Events on Dungeness Crab at the Mouth of the Columbia River Ocean Tracking Network 17851 2023-09-22T19:58:38.000Z
4eaf3ab8-463a-4c8d-838a-9b9c6ca4bfd3 SeamountsOnline U.S. Geological Survey HQ; Scripps Institution of Oceanography 95 2023-07-07T19:52:54.000Z

5 Build every archive

Code
results <- list()
findings <- list()

for (k in plan$dataset_key) {
  cat(glue("\n--- {k} ---"), "\n")
  d_dir <- file.path(OUT_DIR, k)

  # a release is frozen, so an archive already built for THIS version cannot differ: the
  # zip + its manifest are the fingerprint, and a re-render reuses them instead of running
  # the three DwC queries again (2026-09-06). Another version, or a missing zip, rebuilds.
  prev_zip <- file.path(OUT_DIR, glue("{k}_{RELEASE}.zip"))
  prev_man <- file.path(OUT_DIR, glue("{k}_manifest.json"))
  if (file.exists(prev_zip) && file.exists(prev_man)) {
    pm <- jsonlite::fromJSON(prev_man, simplifyVector = TRUE)
    if (identical(pm$version, RELEASE) && nzchar(pm$content_hash %||% "")) {
      cat(glue("archive for {RELEASE} already built ({basename(prev_zip)}, ",
               "content_hash {substr(pm$content_hash, 1, 12)}) — not rebuilt"), "\n")
      results[[k]] <- tibble(dataset_key = k, status = "built",
                             n_event = pm$counts$event %||% NA_integer_,
                             n_occurrence = pm$counts$occurrence %||% NA_integer_,
                             n_emof = pm$counts$emof %||% NA_integer_,
                             content_hash = pm$content_hash, archive = basename(prev_zip))
      next
    }
  }

  ev <- dwc_event(con, k, gear = reg$gear, measurement_type = reg$measurement_type)
  oc <- dwc_occurrence(con, k, life_stage = reg$life_stage,
                       measurement_type = reg$measurement_type)
  mf <- dwc_emof(con, k, occurrence = oc, measurement_type = reg$measurement_type,
                 env = TRUE)

  chk <- dwc_check(ev, oc, mf, dataset_key = k)
  findings[[k]] <- chk
  print(chk[, c("finding", "level", "n", "detail")], row.names = FALSE)

  if (any(chk$level == "error")) {
    # a failing dataset gets NO zip: a broken archive at OBIS is worse than a
    # missing one, and the finding is what says why
    cat(glue("SKIPPED — {sum(chk$level == 'error')} error finding(s); no archive written"), "\n")
    results[[k]] <- tibble(dataset_key = k, status = "failed checks",
                           n_event = nrow(ev), n_occurrence = nrow(oc), n_emof = nrow(mf),
                           content_hash = NA_character_, archive = NA_character_)
    next
  }

  ipt <- dist |> filter(dataset_key == k, portal == "ipt") |> pull(id)
  obs_id <- dist |> filter(dataset_key == k, portal == "obis") |> pull(id)
  a <- dwc_archive(
    d_dir, ev, oc, mf,
    eml_path        = eml_path_of(k),
    dataset_key     = k,
    version         = RELEASE,
    ipt_resource    = if (length(ipt)) ipt[1] else NULL,
    obis_dataset_id = if (length(obs_id)) obs_id[1] else NULL)
  cat(glue("wrote {basename(a$zip)} ({round(file.size(a$zip) / 1e6, 1)} MB), ",
           "content_hash {substr(a$content_hash, 1, 12)}"), "\n")
  results[[k]] <- tibble(dataset_key = k, status = "built",
                         n_event = nrow(ev), n_occurrence = nrow(oc), n_emof = nrow(mf),
                         content_hash = a$content_hash, archive = basename(a$zip))
}
--- calcofi_phyllosoma --- 
archive for v2026.09.06 already built (calcofi_phyllosoma_v2026.09.06.zip, content_hash eeac6f9d2b21) — not rebuilt 
--- calcofi_phytoplankton --- 
archive for v2026.09.06 already built (calcofi_phytoplankton_v2026.09.06.zip, content_hash 31716f4afb98) — not rebuilt 
--- cce-lter_euphausiids --- 
archive for v2026.09.06 already built (cce-lter_euphausiids_v2026.09.06.zip, content_hash 938c91caed88) — not rebuilt 
--- cce-lter_zoodb --- 
archive for v2026.09.06 already built (cce-lter_zoodb_v2026.09.06.zip, content_hash 25e5fadb74f6) — not rebuilt 
--- cce-lter_zooscan --- 
archive for v2026.09.06 already built (cce-lter_zooscan_v2026.09.06.zip, content_hash dd0de501d64f) — not rebuilt 
--- cdfw_dungeness-crab --- 
archive for v2026.09.06 already built (cdfw_dungeness-crab_v2026.09.06.zip, content_hash bc22f6f9d5e8) — not rebuilt 
--- farallon_bird-mammal --- 
archive for v2026.09.06 already built (farallon_bird-mammal_v2026.09.06.zip, content_hash 4719d33ecd04) — not rebuilt 
--- sio_mesopelagic-fish --- 
archive for v2026.09.06 already built (sio_mesopelagic-fish_v2026.09.06.zip, content_hash ccf7fc8301ed) — not rebuilt 
--- swfsc_cufes --- 
archive for v2026.09.06 already built (swfsc_cufes_v2026.09.06.zip, content_hash f678d0a7b61a) — not rebuilt 
--- swfsc_ichthyo --- 
archive for v2026.09.06 already built (swfsc_ichthyo_v2026.09.06.zip, content_hash 04597f97e066) — not rebuilt 
Code
res <- bind_rows(results)
Code
kable(res, caption = glue("Darwin Core archives written for {RELEASE}"))
Darwin Core archives written for v2026.09.06
dataset_key status n_event n_occurrence n_emof content_hash archive
calcofi_phyllosoma built 1928 1859 369 eeac6f9d2b21ecf15c80c33a3b41fcd2 calcofi_phyllosoma_v2026.09.06.zip
calcofi_phytoplankton built 470 157898 0 31716f4afb98bdfada4a1077522be568 calcofi_phytoplankton_v2026.09.06.zip
cce-lter_euphausiids built 7758 100505 0 938c91caed8878e51c56afaf4f672390 cce-lter_euphausiids_v2026.09.06.zip
cce-lter_zoodb built 581 30948 0 25e5fadb74f6d34966dcee094d68f2bc cce-lter_zoodb_v2026.09.06.zip
cce-lter_zooscan built 1542 103312 0 dd0de501d64fdd4adf67736d44736f47 cce-lter_zooscan_v2026.09.06.zip
cdfw_dungeness-crab built 846 1456 641 bc22f6f9d5e8921ef13e7d141beb8324 cdfw_dungeness-crab_v2026.09.06.zip
farallon_bird-mammal built 64544 68899 87813 4719d33ecd04d001f5c6fc1246097db9 farallon_bird-mammal_v2026.09.06.zip
sio_mesopelagic-fish built 109 1392 0 ccf7fc8301ed6eba248fcb42dee229b3 sio_mesopelagic-fish_v2026.09.06.zip
swfsc_cufes built 49656 284097 0 f678d0a7b61acafc0afb04af2b825d67 swfsc_cufes_v2026.09.06.zip
swfsc_ichthyo built 213813 482250 613576 04597f97e066ee7c5f897589842d65de swfsc_ichthyo_v2026.09.06.zip
Code
fin <- bind_rows(findings)
if (nrow(fin)) kable(fin[fin$finding != "ok", ],
                     caption = "every finding, by dataset (error = no archive written)")

6 Stage the archives where a reviewer can see them

An archive is uploaded to the IPT by hand and only after its provider agrees, but the bundle itself should be inspectable before that — by the provider, and by the dataset page (calcofi.io/datasets/{dataset_key}/ lists it under Archives & portals as “built, not deposited”). So every archive built here, with its manifest, is copied to the public bucket at a deterministic address: gs://calcofi-db/publish/dwca/{dataset_key}/{dataset_key}_{version}.zip (+ {dataset_key}_manifest.json). A staging run stays local.

Code
if (!STAGING && nrow(res) && any(res$status == "built")) {
  for (i in which(res$status == "built")) {
    k <- res$dataset_key[i]
    # dwc_archive() writes the zip beside the dataset's folder (OUT_DIR root) and the manifest
    # under the same name pattern; look in both places so a layout change cannot silently skip
    zip_path <- c(file.path(OUT_DIR, res$archive[i]), file.path(OUT_DIR, k, res$archive[i]))
    zip_path <- zip_path[file.exists(zip_path)][1]
    man_path <- c(file.path(OUT_DIR, glue("{k}_manifest.json")), file.path(OUT_DIR, k, glue("{k}_manifest.json")))
    man_path <- man_path[file.exists(man_path)][1]
    if (is.na(zip_path)) { cat(glue("{k}: archive {res$archive[i]} not found on disk; not staged"), "\n"); next }
    put_gcs_file(zip_path, glue("gs://calcofi-db/publish/dwca/{k}/{basename(zip_path)}"))
    if (!is.na(man_path))
      put_gcs_file(man_path, glue("gs://calcofi-db/publish/dwca/{k}/{basename(man_path)}"))
    cat(glue("staged {k}: https://storage.googleapis.com/calcofi-db/publish/dwca/{k}/{basename(zip_path)}"), "\n")
  }
} else cat("staging run or nothing built: nothing staged\n")
staged calcofi_phyllosoma: https://storage.googleapis.com/calcofi-db/publish/dwca/calcofi_phyllosoma/calcofi_phyllosoma_v2026.09.06.zip 
staged calcofi_phytoplankton: https://storage.googleapis.com/calcofi-db/publish/dwca/calcofi_phytoplankton/calcofi_phytoplankton_v2026.09.06.zip 
staged cce-lter_euphausiids: https://storage.googleapis.com/calcofi-db/publish/dwca/cce-lter_euphausiids/cce-lter_euphausiids_v2026.09.06.zip 
staged cce-lter_zoodb: https://storage.googleapis.com/calcofi-db/publish/dwca/cce-lter_zoodb/cce-lter_zoodb_v2026.09.06.zip 
staged cce-lter_zooscan: https://storage.googleapis.com/calcofi-db/publish/dwca/cce-lter_zooscan/cce-lter_zooscan_v2026.09.06.zip 
staged cdfw_dungeness-crab: https://storage.googleapis.com/calcofi-db/publish/dwca/cdfw_dungeness-crab/cdfw_dungeness-crab_v2026.09.06.zip 
staged farallon_bird-mammal: https://storage.googleapis.com/calcofi-db/publish/dwca/farallon_bird-mammal/farallon_bird-mammal_v2026.09.06.zip 
staged sio_mesopelagic-fish: https://storage.googleapis.com/calcofi-db/publish/dwca/sio_mesopelagic-fish/sio_mesopelagic-fish_v2026.09.06.zip 
staged swfsc_cufes: https://storage.googleapis.com/calcofi-db/publish/dwca/swfsc_cufes/swfsc_cufes_v2026.09.06.zip 
staged swfsc_ichthyo: https://storage.googleapis.com/calcofi-db/publish/dwca/swfsc_ichthyo/swfsc_ichthyo_v2026.09.06.zip 

7 Registration status — when is an upload due?

Code
mans <- Sys.glob(file.path(OUT_DIR, "*_manifest.json"))
if (length(mans)) {
  st <- bind_rows(lapply(mans, dwc_manifest_status))
  kable(st, caption = paste(
    "the manifest each archive carries: `built, not uploaded` until the IPT copy is",
    "made, then `published (vX)` while the uploaded bytes are these bytes and",
    "`stale — data changed in vY` once they are not. This is what `registrations[]`",
    "in datasets.json reads."))
} else cat("no manifests yet\n")
the manifest each archive carries: built, not uploaded until the IPT copy is made, then published (vX) while the uploaded bytes are these bytes and stale — data changed in vY once they are not. This is what registrations[] in datasets.json reads.
dataset_key version content_hash ipt_resource obis_dataset_id uploaded_utc status
calcofi_phyllosoma v2026.09.06 eeac6f9d2b21ecf15c80c33a3b41fcd2 built, not uploaded
calcofi_phytoplankton v2026.09.06 31716f4afb98bdfada4a1077522be568 built, not uploaded
cce-lter_euphausiids v2026.09.06 938c91caed8878e51c56afaf4f672390 built, not uploaded
cce-lter_zoodb v2026.09.06 25e5fadb74f6d34966dcee094d68f2bc built, not uploaded
cce-lter_zooscan v2026.09.06 dd0de501d64fdd4adf67736d44736f47 built, not uploaded
cdfw_dungeness-crab v2026.09.06 bc22f6f9d5e8921ef13e7d141beb8324 built, not uploaded
farallon_bird-mammal v2026.09.06 4719d33ecd04d001f5c6fc1246097db9 built, not uploaded
sio_mesopelagic-fish v2026.09.06 ccf7fc8301ed6eba248fcb42dee229b3 built, not uploaded
swfsc_cufes v2026.09.06 f678d0a7b61acafc0afb04af2b825d67 built, not uploaded
swfsc_ichthyo v2026.09.06 04597f97e066ee7c5f897589842d65de calcofi_ichthyo 0e223f55-c826-4513-ae9a-b04cbf2e189c built, not uploaded

8 Upload — a deliberate manual step

NoteWhat this notebook does NOT do

It does not upload. The OBIS-USA IPT (ipt-obis.gbif.us) holds CalCOFI’s resources under Ben’s login, and an upload is made only after the dataset’s provider has agreed (Decision 21) and only when the manifest’s content_hash differs from the published copy’s. The steps are in docs/portals.qmd § OBIS; in short: create or open the resource, upload the zip’s five files as the source, map the Event core + the two extensions from meta.xml, publish a new version, then record the resulting OBIS dataset id in metadata/distribution.csv and stamp uploaded_utc / uploaded_hash into the archive’s {dataset_key}_manifest.json.

Code
dbDisconnect(con, shutdown = TRUE)
cat("done\n")
done