CalCOFI.io CalCOFI.io Workflows

Ingest CCE-LTER Picoplankton and Bacteria Abundance

Published

2026-10-04

1 Overview

Source: Datazoo table download (https://oceaninformatics.ucsd.edu/datazoo/catalogs/ccelter/datasets/159/datatables/159/download), 16,017 rows, 16 columns (73 cruises, 2004-11-02 to 2023-07-18).

  • Provider: cce-lter (matches the existing provider group used by ingest_cce-lter_euphausiids/_zoodb/_zooscan – an earlier draft of this notebook used the unhyphenated ccelter, which would have created a duplicate, inconsistent provider group)
  • Grain: one row per bottle/depth per cast (station-level, multiple depths per cast)
  • Scope decision: no bounding-box filter applied – source is already scoped to CalCOFI/CCE cruises by the provider. studyName (e.g. 2004-11-02-C-33RR) is the cruise identifier used for key resolution, NOT the Cruise column (which is a separate YYYYMM short code, e.g. 200411 – kept as a raw field but not used for joins).
  • Completeness: Depth, Heterotrophic Bacteria, Synechococcus, and Picoeukaryotes are >99.9% populated. Prochlorococcus is ~80% populated (3,228/16,017 blank) – plausible non-detection at higher latitudes/depths, not yet confirmed with the provider (see Questions, Q01). Notes is 100% blank in the current export; it is read and normalized to NA rather than dropped, so a future export that starts populating it flows through without a schema change. No literal-zero values appear in any measurement column in this export (confirmed directly against the raw file) – unlike ZooDB, there is no analyzed-but-absent convention to encode; blank simply means not measured.
Code
graph LR
  A[Datazoo table 159<br/>16,017 rows] --> B[rename + type-cast]
  B --> C[picoplankton_bacteria_bottle<br/>position + depth, one row per bottle]
  B --> D[picoplankton_bacteria_measurement<br/>long format: 4 measurement types]
  C -.studyName -> cruise_key.-> E[(shared refs)]
  D -.bottle_id.-> C

graph LR
  A[Datazoo table 159<br/>16,017 rows] --> B[rename + type-cast]
  B --> C[picoplankton_bacteria_bottle<br/>position + depth, one row per bottle]
  B --> D[picoplankton_bacteria_measurement<br/>long format: 4 measurement types]
  C -.studyName -> cruise_key.-> E[(shared refs)]
  D -.bottle_id.-> C

2 Setup

Code
devtools::load_all(here::here("../calcofi4db"))
devtools::load_all(here::here("../calcofi4r"))
librarian::shelf(
  CalCOFI/calcofi4db, CalCOFI/calcofi4r,
  DBI, dplyr, DT, fs, glue, here, htmltools, janitor, jsonlite, knitr,
  lubridate, purrr, readr, sf, stringr, tibble, tidyr, units,
  quiet = T)
options(readr.show_col_types = F)
options(DT.options = list(scrollX = TRUE))

source(here("libs/ingest.R"))
cc <- read_calcofi_meta(here("ingest_cce-lter_picoplankton-bacteria.qmd"))
provider <- cc$provider
dataset <- cc$dataset
dataset_name <- cc$dataset_meta$dataset_name
tables_owned <- cc$tables_owned
dir_label <- glue("{provider}_{dataset}")
dir_parquet <- here(glue("data/parquet/{dir_label}"))
dir_stage <- cc_stage_path("parquet", dir_label, create = TRUE)
db_path <- here(glue("data/wrangling/{dir_label}.duckdb"))
meas_type_csv <- here("metadata/measurement_type.csv")
d_meas_type <- read_measurement_type(meas_type_csv)

if (overwrite) {
  if (file_exists(db_path)) file_delete(db_path)
  if (file_exists(paste0(db_path, ".wal"))) file_delete(paste0(db_path, ".wal"))
  if (dir_exists(paste0(db_path, ".tmp"))) dir_delete(paste0(db_path, ".tmp"))
}
dir_create(dirname(db_path))
con <- get_duckdb_con(db_path)
load_duckdb_extension(con, "spatial")

3 Read Source Data

Fetched reproducibly by libs/download_picoplankton_bacteria.R into {dir_data}/cce-lter/picoplankton-bacteria/. The dataset page links to the Datazoo table download, but that URL redirects to a Login / Accept Data Agreement form and so cannot be fetched unattended; EDI carries the same table (the Datazoo DOI resolves to knb-lter-cce.159), so we pull from there. The revision is deliberately unpinned β€” this series is ongoing β€” and the revision actually used is reported below. Columns are renamed per metadata/cce-lter/picoplankton-bacteria/flds_redefine.csv.

Code
source(here("libs/download_picoplankton_bacteria.R"))
pico_csv <- download_picoplankton_bacteria(
  path_expand(glue("{dir_data}/{provider}/{dataset}")),
  overwrite = overwrite_all)
EDI: using cached PicoplanktonandBacteriaAbundance.csv 
Code
stopifnot("picoplankton/bacteria CSV not found" = file_exists(pico_csv))

sync_to_gcs(
  local_dir = path_dir(pico_csv),
  gcs_prefix = glue("archive/{provider}/{dataset}"),
  bucket = "calcofi-files-public",
  exclude = c(".DS_Store", "*.tmp", "*.gdoc"))
# A tibble: 0 Γ— 4
# β„Ή 4 variables: file <chr>, action <chr>, size <dbl>, reason <chr>
Code
d_raw <- read_csv(pico_csv, col_types = cols(.default = "c"))
cat(glue("Read {format(nrow(d_raw), big.mark=',')} rows, {ncol(d_raw)} columns"), "\n")
Read 16,017 rows, 16 columns 

4 Clean, Type-Cast

Canonical names from metadata/cce-lter/picoplankton-bacteria/flds_redefine.csv. studyName (the cruise identifier string, e.g. 2004-11-02-C-33RR) is retained separately from Cruise (the YYYYMM short code) – only the former is used for cruise-key resolution. The four measurement columns (Heterotrophic Bacteria, Prochlorococcus, Synechococcus, Picoeukaryotes) are NOT kept as wide columns – they are pivoted to long format below, following the bottle_measurement/zoodb_measurement pattern used elsewhere in the integrated DB.

Code
d_clean <- d_raw |>
  transmute(
    study_name       = studyName,
    cruise_code      = Cruise,
    datetime_utc     = as_datetime(`Datetime GMT`),
    latitude         = suppressWarnings(as.numeric(`Latitude (ΒΊ)`)),
    longitude        = suppressWarnings(as.numeric(`Longitude (ΒΊ)`)),
    line             = suppressWarnings(as.numeric(Line)),
    station          = suppressWarnings(as.numeric(Station)),
    cast_number      = suppressWarnings(as.integer(`Cast Number`)),
    bottle_number    = suppressWarnings(as.integer(`Bottle Number`)),
    assoc_bottle_number = suppressWarnings(as.integer(`Associated Bottle Number`)),
    depth_m          = suppressWarnings(as.numeric(`Depth (m)`)),
    het_bacteria_n_ml    = suppressWarnings(as.numeric(`Heterotrophic Bacteria (number/ml)`)),
    prochlorococcus_n_ml = suppressWarnings(as.numeric(`Prochlorococcus (number/ml)`)),
    synechococcus_n_ml   = suppressWarnings(as.numeric(`Synechococcus (number/ml)`)),
    picoeukaryotes_n_ml  = suppressWarnings(as.numeric(`Picoeukaryotes (number/ml)`)),
    notes            = na_if(Notes, "")) |>
  mutate(
    site_key = if_else(
      is.na(line) | is.na(station), NA_character_,
      sprintf("%05.1f %05.1f", line, station)),
    bottle_id = row_number())

n_all <- nrow(d_clean)
cat(glue("{format(n_all, big.mark=',')} rows read, no bounding-box filter applied ",
         "(source pre-scoped to CalCOFI/CCE cruises)"), "\n")
16,017 rows read, no bounding-box filter applied (source pre-scoped to CalCOFI/CCE cruises) 
Code
dbWriteTable(con, "picoplankton_bacteria_bottle", d_clean, overwrite = TRUE)

5 Pivot Measurements to Long Format

Unlike ZooDB, this source has no analyzed-but-absent convention – every blank cell in the raw export is genuinely not measured (confirmed: zero literal 0 values appear in Heterotrophic Bacteria or Prochlorococcus, only blanks). So this pivot simply drops NA, with no explicit-zero retention logic needed.

measurement_type codes are unit-less (het_bacteria, not het_bacteria_n_ml) – units live in measurement_type.csv’s units column, matching the convention used by bottle_measurement/ zoodb_measurement elsewhere in the DB. An earlier draft baked the unit into the type code itself; fixed here.

Code
pb_meas_map <- tribble(
  ~measurement_type,     ~value_col,
  "het_bacteria",         "het_bacteria_n_ml",
  "prochlorococcus",      "prochlorococcus_n_ml",
  "synechococcus",        "synechococcus_n_ml",
  "picoeukaryotes",       "picoeukaryotes_n_ml")

sql_parts <- purrr::pmap_chr(pb_meas_map, function(measurement_type, value_col) {
  glue(
    "SELECT bottle_id, '{measurement_type}' AS measurement_type,
     CAST({value_col} AS DOUBLE) AS measurement_value
     FROM picoplankton_bacteria_bottle WHERE {value_col} IS NOT NULL")
})

sql_create <- glue(
  "CREATE OR REPLACE TABLE picoplankton_bacteria_measurement AS
   SELECT ROW_NUMBER() OVER (ORDER BY bottle_id, measurement_type) AS measurement_id, *
   FROM (
     {paste(sql_parts, collapse = '\nUNION ALL\n')}
   ) sub")
dbExecute(con, sql_create)
[1] 60802
Code
n_meas <- dbGetQuery(con, "SELECT COUNT(*) FROM picoplankton_bacteria_measurement")[[1]]
cat(glue("picoplankton_bacteria_measurement: {format(n_meas, big.mark=',')} rows"), "\n")
picoplankton_bacteria_measurement: 60,802 rows 
Code
for (col in pb_meas_map$value_col) {
  tryCatch(
    dbExecute(con, glue('ALTER TABLE picoplankton_bacteria_bottle DROP COLUMN "{col}"')),
    error = function(e) NULL)
}

6 Register Measurement Types

Registers this dataset’s measurement_type codes into the shared metadata/measurement_type.csv and loads the measurement_type table into this connection – an earlier draft referenced measurement_type as a FK target in Schema Documentation / Validate without ever creating it, which would fail. Pattern matches ingest_calcofi_phyllosoma.qmd’s finalize step.

Two layers, since 2026-09-12 (measurement-faces plan D9, Ben’s decision):

  • The four source codes (synechococcus, prochlorococcus, picoeukaryotes, het_bacteria) stay in the registry and stay the vocabulary of the source-shaped picoplankton_bacteria_measurement table. They are not deleted – a registry row is statused, never removed – but from this render on they name a taxon, not an obs measurement type, so they stop appearing in obs_env and therefore in the measurements catalog at the next release.
  • One canonical type, picoplankton_abundance, is what obs carries. The organism moves into obs.taxon_key, which is what puts these counts under Biology beside every other cell count and gives Synechococcus and Prochlorococcus a species page. It is registered with register_measurement_types(), the helper that writes the registry with na = "" and keeps it sorted.
Code
pb_types <- tribble(
  ~measurement_type, ~description,                              ~units,
  "het_bacteria",     "Heterotrophic bacteria abundance (FCM)",  "number/ml",
  "prochlorococcus",  "Prochlorococcus abundance (FCM); ~80% populated, see Q01", "number/ml",
  "synechococcus",    "Synechococcus abundance (FCM)",           "number/ml",
  "picoeukaryotes",   "Picoeukaryote abundance (FCM)",           "number/ml") |>
  mutate(is_canonical = TRUE,
         grain        = "obs",
         `_source_column` = case_when(
           measurement_type == "het_bacteria"    ~ "Heterotrophic Bacteria (number/ml)",
           measurement_type == "prochlorococcus" ~ "Prochlorococcus (number/ml)",
           measurement_type == "synechococcus"   ~ "Synechococcus (number/ml)",
           measurement_type == "picoeukaryotes"  ~ "Picoeukaryotes (number/ml)"),
         `_source_table` = "picoplankton_bacteria_measurement",
         `_source_datasets` = "cce-lter_picoplankton-bacteria",
         `_qual_column` = NA_character_, `_prec_column` = NA_character_)

# upsert so a units/description correction propagates on re-run, and keep the
# registry sorted so its on-disk order is deterministic across ingests
# upsert, not delete-and-replace: the literal below carries no valid_min/
# valid_max, and the naive `filter(!= x) |> bind_rows()` destroyed those
# curated bounds on every re-run (it silently un-declared euphausiid_abundance
# and the picoplankton types mid-release, failing the bounds gate).
d_meas_type <- upsert_measurement_types(d_meas_type, pb_types)
new_types <- pb_types
write_csv(d_meas_type, meas_type_csv, na = "")

# the canonical type `obs` carries. One quantity -- cells per millilitre by flow
# cytometry -- for all four populations; WHICH population is `obs.taxon_key`, not
# a suffix on the type name. No `nerc_p01`: P700A90Z / P701A90Z each name one
# organism's count, so neither is an exact concept for the pooled type, and an
# inexact controlled-vocabulary id is left empty by the registry rule. The two
# organism-specific P01s stay on their own rows above, where they are exact.
pb_canonical <- tribble(
  ~measurement_type,        ~description, ~units,
  "picoplankton_abundance",
  paste("Picoplankton and bacteria cell abundance by flow cytometry (FCM);",
        "the population counted is obs.taxon_key"),
  "number/ml") |>
  mutate(valid_min = 0, is_canonical = TRUE, grain = "obs",
         category  = "Picoplankton & Bacteria",
         `_source_column` = paste(
           "Synechococcus (number/ml)", "Prochlorococcus (number/ml)",
           "Picoeukaryotes (number/ml)", "Heterotrophic Bacteria (number/ml)",
           sep = "; "),
         `_source_table`    = "picoplankton_bacteria_measurement",
         `_source_datasets` = "cce-lter_picoplankton-bacteria",
         `_qual_column` = NA_character_, `_prec_column` = NA_character_,
         units_nerc_p06 = "http://vocab.nerc.ac.uk/collection/P06/current/UCML/")
d_meas_type <- register_measurement_types(pb_canonical, meas_type_csv)

dbWriteTable(con, "measurement_type", d_meas_type, overwrite = TRUE)
cat(glue("measurement_type: {nrow(d_meas_type)} types registered ",
         "({nrow(new_types)} source codes upserted, canonical ",
         "`picoplankton_abundance` registered)"), "\n")
measurement_type: 230 types registered (4 source codes upserted, canonical `picoplankton_abundance` registered) 

7 Resolve Ship and Cruise Keys

cruise_key in the shared cruise table is a natural key in format YYYY-MM-NODC, built by the real calcofi4db::derive_cruise_key_on_casts() function (confirmed from ship.R). That function needs a ship_code column (matched against ship.ship_nodc) and a datetime_utc column, and accepts the target table directly via table_name= (confirmed via ingest_calcofi_mets.qmd’s usage) – no need to rename anything to casts. This source has no ship_code natively – only studyName (e.g. 2004-11-02-C-33RR), whose trailing code (33RR) is the ship code embedded in the string – so extract that into a ship_code column, then call the real function against picoplankton_bacteria_bottle directly rather than reimplementing its SQL by hand (an earlier draft did this incorrectly).

Code
load_prior_tables(
  con, parquet_dir = cc_stage_path("parquet", "swfsc_ichthyo"),
  tables = c("ship", "cruise", "grid"), geom_tables = c("grid"), as_view = TRUE)
# A tibble: 3 Γ— 3
  table   rows has_geom
  <chr>  <dbl> <lgl>   
1 cruise   694 FALSE   
2 grid     225 TRUE    
3 ship      48 FALSE   
Code
# extract trailing ship code from studyName (e.g. "2004-11-02-C-33RR" -> "33RR")
# NOTE (Q02, resolved): verified against the live release DB for 3 sample
# codes (33RR, 32NM, 31JD); all matched real casts.parquet rows. Still not
# verified across all 73 distinct studyName formats in this dataset.
dbExecute(con, "
  ALTER TABLE picoplankton_bacteria_bottle ADD COLUMN IF NOT EXISTS ship_code VARCHAR")
[1] 0
Code
dbExecute(con, "
  UPDATE picoplankton_bacteria_bottle
  SET ship_code = regexp_extract(study_name, '[0-9A-Z]+$')")
[1] 16017
Code
# the study's own designation ("2004-11-02-C-33RR" -> 200411, the cruise START
# month) is the fallback when no reference cruise's date span holds the sample
dbExecute(con, "
  ALTER TABLE picoplankton_bacteria_bottle ADD COLUMN IF NOT EXISTS cruise_ym VARCHAR")
[1] 0
Code
dbExecute(con, "
  UPDATE picoplankton_bacteria_bottle
  SET cruise_ym = regexp_replace(study_name, '^(\\d{4})-(\\d{2}).*$', '\\1\\2')")
[1] 16017
Code
cruise_key_result <- derive_cruise_key_on_casts(
  con, table_name = "picoplankton_bacteria_bottle", datetime_col = "datetime_utc",
  cruise_ym_col = "cruise_ym")
cruise_key_result$key_stats |>
  datatable(caption = "cruise_key resolution: span > source designation > month")
Code
n_ck <- dbGetQuery(con, "SELECT COUNT(*) FROM picoplankton_bacteria_bottle WHERE cruise_key IS NOT NULL")[[1]]
n_all <- dbGetQuery(con, "SELECT COUNT(*) FROM picoplankton_bacteria_bottle")[[1]]
cat(glue("cruise_key match: {n_ck}/{n_all} ({round(100*n_ck/n_all,1)}%)"), "\n")
cruise_key match: 8801/16017 (54.9%) 

8 Add Spatial

Code
add_point_geom(con, "picoplankton_bacteria_bottle", lon_col = "longitude", lat_col = "latitude")
assign_grid_key(con, "picoplankton_bacteria_bottle") |> datatable(caption = "Grid assignment")

9 Load Dataset Metadata

Code
d_dataset <- ingest_yaml_to_dataset_df(read_ingest_yaml(here()))
dbWriteTable(con, "dataset", d_dataset, overwrite = TRUE)
cat(glue("dataset: {nrow(d_dataset)} datasets registered"), "\n")
dataset: 22 datasets registered 

10 Schema Documentation

Code
pb_rels <- list(
  primary_keys = list(
    picoplankton_bacteria_bottle = "bottle_id",
    picoplankton_bacteria_measurement = "measurement_id",
    measurement_type = "measurement_type"),
  foreign_keys = list(
    list(table = "picoplankton_bacteria_measurement", column = "bottle_id",
         ref_table = "picoplankton_bacteria_bottle", ref_column = "bottle_id"),
    list(table = "picoplankton_bacteria_measurement", column = "measurement_type",
         ref_table = "measurement_type", ref_column = "measurement_type")))

cc_erd(
  con, tables = c("picoplankton_bacteria_bottle", "picoplankton_bacteria_measurement",
                  "measurement_type", "dataset"),
  rels = pb_rels,
  colors = list(lightblue = c("picoplankton_bacteria_bottle", "picoplankton_bacteria_measurement"),
                lightyellow = "measurement_type", white = "dataset"))

Code
build_relationships_json(
  rels = pb_rels, output_dir = dir_parquet, provider = provider, dataset = dataset)
[1] "/Users/bbest/Github/CalCOFI/workflows/data/parquet/cce-lter_picoplankton-bacteria/relationships.json"

11 Validate

Code
results <- validate_for_release(con, checks = "all", strict = FALSE)
cat("Validation:", ifelse(results$passed, "PASSED", "FAILED"), "\n")
Validation: FAILED 
Code
if (length(results$errors) > 0)
  cat("Errors:\n", paste("-", results$errors, collapse = "\n"), "\n")
Errors:
 - Table 'picoplankton_bacteria_bottle' has 7216 NULL values in required column 'ship_key'
- Table 'picoplankton_bacteria_bottle' has 7216 NULL values in required column 'cruise_key' 
Code
# NULL cruise_key is the expected unmatched remainder pending confirmation of
# study_name -> cruise_key format (see Questions, Q02); not a hard failure.
n_dup <- dbGetQuery(con,
  "SELECT COUNT(*) FROM (SELECT bottle_id, COUNT(*) n FROM picoplankton_bacteria_bottle GROUP BY bottle_id HAVING COUNT(*)>1)")[[1]]
cat(glue("picoplankton_bacteria_bottle bottle_id duplicates: {n_dup}"), "\n")
picoplankton_bacteria_bottle bottle_id duplicates: 0 
Code
n_orphan <- dbGetQuery(con,
  "SELECT COUNT(*) FROM picoplankton_bacteria_measurement m
   LEFT JOIN picoplankton_bacteria_bottle b ON m.bottle_id = b.bottle_id
   WHERE b.bottle_id IS NULL")[[1]]
cat(glue("Orphan measurements (no matching bottle): {n_orphan}"), "\n")
Orphan measurements (no matching bottle): 0 
Code
n_orphan_type <- dbGetQuery(con,
  "SELECT COUNT(*) FROM picoplankton_bacteria_measurement m
   WHERE m.measurement_type NOT IN (SELECT measurement_type FROM measurement_type)")[[1]]
cat(glue("Orphan measurement_types (not registered): {n_orphan_type}"), "\n")
Orphan measurement_types (not registered): 0 

12 Data Preview

Code
cols <- dbGetQuery(con,
  "SELECT column_name FROM information_schema.columns
   WHERE table_name='picoplankton_bacteria_bottle' AND data_type NOT LIKE 'GEOMETRY%'")$column_name
dbGetQuery(con, glue("SELECT {paste(cols, collapse=', ')} FROM picoplankton_bacteria_bottle LIMIT 100")) |>
  datatable(caption = "picoplankton_bacteria_bottle β€” first 100 rows", rownames = FALSE, filter = "top")
Code
dbGetQuery(con, "SELECT * FROM picoplankton_bacteria_measurement LIMIT 100") |>
  datatable(caption = "picoplankton_bacteria_measurement β€” first 100 rows", rownames = FALSE, filter = "top")

13 Emit Core Tables

Project this dataset into the shared consolidated core model (design_env-bio-consolidation.md), the same projection release_database.qmd uses to assemble the cross-dataset release. This is a bottle-shaped sample β€” leaf grain bottle_id, and since the export carries no cast-level event table the bottle is its own root β€” with an obs headline of the four flow-cytometry counts.

Realm bio, since 2026-09-12 (measurement-faces plan D9; Ben: these counts belong under Biology, where the datasets catalog already files this dataset). Until then the arm emitted realm = 'env' with a NULL taxon_key, on the reading that β€œthe four FCM types ARE the measurement vocabulary”. They are not: each one names an organism counted by the same instrument in the same units. So the quantity is one canonical measurement_type (picoplankton_abundance, cells per ml) and the organism is obs.taxon_key β€” which is the shape every other cell count in the database already has, and the only shape that reaches build_taxa_catalog() (it reads obs_bio) so that /species/worms-160572/ is a page rather than a 404.

Two of the four populations are genera an authority keys β€” Synechococcus (WoRMS 160572) and Prochlorococcus (WoRMS 345515), the same organisms NERC’s S25 attaches to the P01s P700A90Z and P701A90Z on the two registry rows. The other two, picoeukaryotes and het_bacteria, are operational classes: a size fraction of the eukaryotes and a trophic fraction of the bacteria, neither of which is a taxon. Their identity is not dropped β€” they take a dataset-local taxon_key (cce-lter_picoplankton-bacteria:picoeukaryotes, …:het_bacteria) declared one at a time in local_allow below and in release_database.qmd’s TAXON_LOCAL_ALLOW, exactly as ZooScan’s eggs / multiples / nauplii / others and phytoplankton’s undefined codes do. That is the established mechanism for a real class in the source that no authority can key; check_taxon_ids() fails the release on any dataset-local key not on that list, so a genuinely unresolved taxon cannot hide among them.

Code
ds_key <- "cce-lter_picoplankton-bacteria"
# filter the crosswalk to THIS dataset -- an unfiltered read leaks other datasets'
# taxa into this shard. This dataset has no measurement_taxon.csv rows: the
# composite-decomposition registry is for an ingest whose taxon comes ONLY from a
# measurement_type name (cufes, phyllosoma, dungeness crab). Here the vocabulary
# is declared with append_dataset_taxon() instead, because that is the only path
# that mints a dataset-local key for the two operational classes -- and two
# sources of truth for one taxon_key is what the taxon plan's D1 removed.
mt_taxon <- read_csv(here("metadata/measurement_taxon.csv"),
                     col_types = cols(worms_id = "i", itis_id = "i",
                                      bin_value = "d", .default = "c")) |>
  filter(dataset_key == ds_key)
tx_over  <- read_csv(here("metadata/taxon_override.csv"), show_col_types = FALSE)

# This projection lives here, in the notebook that owns the dataset, not in a
# switch(dataset_key, ...) arm inside calcofi4db. The reusable SHAPES stay in the
# package (sample_arm_self / compat_measurement_sql), so this is a declaration.
#
# The vocabulary is DECLARED here and RESOLVED by the package (taxon plan D1,
# calcofi4db >= 3.29.0): append_dataset_taxon() stages one `dataset_taxon` row
# per source code with `taxon_key` empty and the AphiaID we supply carried along
# as `ds_source_json`; resolve_dataset_taxon() fills the key from the authorities
# and mints a dataset-local key where no authority answers. `ds_taxa_code` is the
# source's own measurement column name, VERBATIM, because that is what the
# measurement rows join on -- rewriting it would orphan every observation.
#
# The two AphiaIDs are not guesses: they are what NERC's S25 (biological entity)
# concept behind each P01 is owl:sameAs (probed 2026-09-11,
# .claude/plans/2026-09-11 measurement-faces-probe/nvs_probe.json).
d_vocab <- tibble::tribble(
  ~ds_taxa_code,      ~ds_scientific_name, ~ds_common_name,            ~worms_id, ~rank,
  # no ds_common_name for the two genera: the source supplies no vernacular, and
  # repeating the scientific name as a "common name" would publish one we invented
  "synechococcus",    "Synechococcus",     NA_character_,              160572L,   "Genus",
  "prochlorococcus",  "Prochlorococcus",   NA_character_,              345515L,   "Genus",
  # no scientific name to look up: a size class and a trophic class, not taxa
  "picoeukaryotes",   NA_character_,       "picoeukaryotes",           NA_integer_, NA_character_,
  "het_bacteria",     NA_character_,       "heterotrophic bacteria",   NA_integer_, NA_character_)
n_staged <- append_dataset_taxon(con, ds_key, d_vocab)

# cross-reference: resolve each taxon against BOTH authorities (cached in
# metadata/taxon_xref.csv, so a re-run costs no API calls). This fills the
# `worms_id` COLUMN on itis:-keyed taxa without touching their key, backfills
# `itis_id` the other way, replaces an id its authority has deprecated so the key
# is always an accepted id, and fetches the real `taxonomic_status` with the date
# it was checked. Must precede the lineage fetch, which should ask about the
# accepted id, not the deprecated one.
ensure_taxon_xref(con, mt_taxon, tx_over,
                  cache_csv = here("metadata/taxon_xref.csv"))

# lineage: fetch each taxon's WoRMS classification (cached in
# metadata/taxon_lineage.csv) and stage it as the `taxon` hierarchy
# build_taxon_reference() reads. Without it a taxon reaches the release with a
# key and a name and NOTHING else -- no rank, no parent_taxon_key, no
# classification -- so hierarchy rollups silently match nothing.
ensure_taxon_lineage(con, mt_taxon, tx_over,
                     cache_csv = here("metadata/taxon_lineage.csv"))
n_taxon    <- build_taxon_reference(con, mt_taxon, tx_over)
n_ds_taxon <- resolve_dataset_taxon(con, mt_taxon, tx_over)
n_tx_group <- build_taxon_group(con, read_taxon_group_rules(here("metadata/taxon_group.csv")))

# the ingest asserts its own crosswalk (D6). Two of the four populations are
# OPERATIONAL, not taxonomic, so they key dataset-locally BY DECLARATION -- one
# key at a time, with the reason, so a genuinely unresolved taxon cannot hide
# among them. The same two keys are declared in release_database.qmd's
# TAXON_LOCAL_ALLOW, which is the gate that fails the release.
local_allow <- tibble::tribble(
  ~ds_taxa_code,    ~why,
  "picoeukaryotes", "a <2-3 um SIZE fraction of the eukaryotic plankton, not a taxon",
  "het_bacteria",   "the heterotrophic (non-cyanobacterial) fraction of the bacteria, a TROPHIC class")
obs_codes <- dbGetQuery(con,
  "SELECT DISTINCT measurement_type AS c FROM picoplankton_bacteria_measurement")$c
chk_taxon <- check_dataset_taxon(
  con, ds_key, allow = paste0(ds_key, ":", local_allow$ds_taxa_code),
  codes = obs_codes)
cat(glue("vocabulary: {n_staged} populations staged; {nrow(local_allow)} keyed ",
         "dataset-locally by declaration; taxon {n_taxon}, ",
         "dataset_taxon {n_ds_taxon}, taxon_group {n_tx_group}"), "\n")
vocabulary: 4 populations staged; 2 keyed dataset-locally by declaration; taxon 13, dataset_taxon 4, taxon_group 0 
Code
append_sample(con, sample_arm_self(
  ds_key, "picoplankton_bacteria_bottle", "bottle_id", "bottle",
  dt_col = "datetime_utc", site_expr = "site_key",
  depth_min = "depth_m", depth_max = "depth_m"))

# realm 'bio'; ONE measurement_type for the quantity, the organism in taxon_key.
# The join to dataset_taxon is INNER on purpose: a source column that never
# reached the vocabulary would silently vanish from obs, and the parity assert
# below (n_obs == every measurement row) is what catches it.
append_obs(con, glue("
  SELECT 'bio', '{ds_key}', {ns_key(ds_key, 'bottle', 'b.bottle_id')},
         b.grid_key, b.cruise_key, b.latitude, b.longitude,
         CAST(b.datetime_utc AS TIMESTAMP), b.depth_m, b.depth_m,
         dt.taxon_key, NULL::VARCHAR, 'picoplankton_abundance', m.measurement_value,
         NULL::VARCHAR, NULL::DOUBLE
  FROM picoplankton_bacteria_measurement m
  JOIN picoplankton_bacteria_bottle b USING (bottle_id)
  JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}'
                       AND dt.ds_taxa_code = m.measurement_type"))

core <- list(
  sample        = dbGetQuery(con, "SELECT COUNT(*) FROM sample")[[1]],
  obs           = dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]],
  taxon         = n_taxon,
  dataset_taxon = n_ds_taxon,
  taxon_group   = n_tx_group)
cat(glue(
  "core projection β€” sample={core$sample %||% 0} obs={core$obs %||% 0} ",
  "taxon={core$taxon %||% 0} dataset_taxon={core$dataset_taxon %||% 0} ",
  "taxon_group={core$taxon_group %||% 0}\n"))
core projection β€” sample=16017 obs=60802 taxon=13 dataset_taxon=4 taxon_group=0
Code
# every measurement on a bottle must reach obs, and must reach it WITH a taxon
n_obs <- dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]]
n_exp <- dbGetQuery(con,
  "SELECT COUNT(*) FROM picoplankton_bacteria_measurement m
   JOIN picoplankton_bacteria_bottle b USING (bottle_id)")[[1]]
stopifnot(
  "obs must be one row per measurement" = n_obs == n_exp,
  "every picoplankton obs must carry a taxon_key" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs WHERE taxon_key IS NULL")[[1]] == 0,
  "obs must carry exactly one measurement_type" =
    dbGetQuery(con, "SELECT COUNT(DISTINCT measurement_type) FROM obs")[[1]] == 1)
cat(glue("obs parity: {format(n_obs, big.mark=',')} rows, all taxon-resolved"), "\n")
obs parity: 60,802 rows, all taxon-resolved 
Code
dbGetQuery(con, "
  SELECT o.taxon_key, t.scientific_name, t.rank, COUNT(*) AS n_obs,
         COUNT(*) FILTER (WHERE o.measurement_value > 0) AS n_present,
         MIN(year(o.datetime)) AS year_min, MAX(year(o.datetime)) AS year_max
  FROM obs o LEFT JOIN taxon t USING (taxon_key)
  GROUP BY ALL ORDER BY n_obs DESC") |>
  datatable(caption = paste(
    "obs by taxon β€” what the species catalog will count. The two genera get a",
    "page at /species/worms-160572/ and /species/worms-345515/; the two",
    "operational classes keep their identity in a declared dataset-local key."),
    rownames = FALSE)
Code
stopifnot(
  "every obs.sample_key must resolve in sample" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs o LEFT JOIN sample s USING (sample_key)
                     WHERE s.sample_key IS NULL")[[1]] == 0)
# serve the retired per-dataset table name as a VIEW over the core, so
# in-notebook consumers and ad-hoc queries keep working against the old name.
# Written out rather than taken from compat_measurement_sql(): that shape returns
# obs.measurement_type, which is now the canonical `picoplankton_abundance` for
# every row, so the view would lose the very thing this table is keyed by. The
# source's own column name comes back from dataset_taxon.ds_taxa_code, which is
# where it is now stored verbatim.
invisible(dbExecute(con, "DROP TABLE IF EXISTS picoplankton_bacteria_measurement_src"))
invisible(dbExecute(con, "ALTER TABLE picoplankton_bacteria_measurement RENAME TO picoplankton_bacteria_measurement_src"))
invisible(dbExecute(con, glue(
  "CREATE OR REPLACE VIEW picoplankton_bacteria_measurement AS
   SELECT o.obs_id                          AS measurement_id,
          split_part(o.sample_key, ':', 3)  AS bottle_id,
          dt.ds_taxa_code                   AS measurement_type,
          o.measurement_value, o.measurement_qual
   FROM obs o
   LEFT JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}'
                             AND dt.taxon_key   = o.taxon_key
   WHERE o.dataset_key = '{ds_key}'")))
cat(glue("compat view picoplankton_bacteria_measurement over obs: ",
         "{dbGetQuery(con, 'SELECT COUNT(*) FROM picoplankton_bacteria_measurement')[[1]]} rows"), "\n")
compat view picoplankton_bacteria_measurement over obs: 60802 rows 

14 Write Parquet Outputs

Code
dir_create(dir_parquet)
mismatches <- list(cruise_keys = collect_cruise_key_mismatches(con, "picoplankton_bacteria_bottle"))
tbls_out <- core_output_tables(con, extra = c("measurement_type", "dataset"))
parquet_stats <- write_parquet_outputs(
  con = con, output_dir = dir_parquet,
  tables = tbls_out,
  sort_by = list(obs = c("grid_key", "measurement_type")),
  strip_provenance = FALSE, mismatches = mismatches)

build_relationships_json(
  rels = core_relationships(tbls_out), output_dir = dir_parquet,
  provider = provider, dataset = dataset)
[1] "/Users/bbest/Github/CalCOFI/workflows/data/parquet/cce-lter_picoplankton-bacteria/relationships.json"
Code
parquet_stats |> mutate(file = basename(path)) |> select(-path) |>
  datatable(caption = "Parquet export statistics")

15 Write Metadata

Code
d_tbls_rd <- read_csv(here("metadata/cce-lter/picoplankton-bacteria/tbls_redefine.csv"))
d_flds_rd <- read_csv(here("metadata/cce-lter/picoplankton-bacteria/flds_redefine.csv"))
metadata_path <- build_metadata_json(
  con = con, d_tbls_rd = d_tbls_rd, d_flds_rd = d_flds_rd,
  metadata_derived_csv = c(here("metadata/core_dictionary.csv"),
                           here("metadata/cce-lter/picoplankton-bacteria/metadata_derived.csv")),
  output_dir = dir_parquet, tables = tbls_out,
  set_comments = TRUE, provider = provider, dataset = dataset,
  workflow_url = cc$workflow_url, tables_owned = tables_owned)
metadata.json documentation gaps (6 tables, 103 columns) β€” these render blank in cc_describe_table() / cc_db_catalog():
tables with no description_md: 2    measurement_type, dataset
columns with no description_md: 43    dataset.provider, dataset.dataset, dataset.dataset_name, dataset.dataset_name_short, dataset.category, dataset.color, dataset.description, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others (+31 more)
measurement columns with no units: 34    dataset.dataset_name_short, dataset.category, dataset.color, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others, dataset.tables, dataset.coverage_temporal, dataset.coverage_spatial, dataset.license (+22 more)
  backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run

16 Upload to GCS

Code
if (Sys.getenv("CALCOFI_SKIP_GCS") == "") {
  sync_to_gcs(local_dir = dir_stage, sidecar_dir = dir_parquet, gcs_prefix = glue("ingest/{dir_label}"), bucket = "calcofi-db")
} else cat("CALCOFI_SKIP_GCS set β€” skipping the upload (local verification run)\n")
# A tibble: 6 Γ— 4
  file    action    size reason        
  <chr>   <chr>    <dbl> <chr>         
1 <rsync> uploaded    NA parallel rsync
2 <rsync> uploaded    NA parallel rsync
3 <rsync> uploaded    NA parallel rsync
4 <rsync> uploaded    NA parallel rsync
5 <rsync> uploaded    NA parallel rsync
6 <rsync> uploaded    NA parallel rsync

17 Questions for Data Providers

Follow-up questions for CCE-LTER (Michael Landry), ranked by importance. Tracked in metadata/cce-lter/picoplankton-bacteria/questions.csv.

Code
# one validated read + render for every ingest: the vocabulary and the column
# order live in calcofi4db, not in 16 hand-written factor() calls
questions_datatable(
  here(cc$questions_file),
  caption = "Questions for the CCE-LTER picoplankton/bacteria data providers (ranked)")

18 Cleanup

Code
close_duckdb(con)
cat(glue("Parquet outputs written to: {dir_parquet}"), "\n")
Parquet outputs written to: /Users/bbest/Github/CalCOFI/workflows/data/parquet/cce-lter_picoplankton-bacteria