Ingest CCE-LTER ZooScan PRPOOS

Published

2026-08-14

1 Overview

Source: ZooScan — the SIO Ocean Informatics ZooScan database (Mark Ohman Lab; interface by Marina Frants). The portal’s bulk download is disabled, so libs/download_zooscan.R scripts its public login + PRPOOS plot CGI (/cgi-bin/tssubplot_new.py), whose returned Plotly page embeds the underlying per-station values as a data:text/csv URI. Each bioclass is fetched in both plot modes and consolidated to zooscan_prpoos.csv (see the Acquire step + by_taxon/_PROVENANCE.md).

  • Provider: cce-lter (Ohman Lab / CCE-LTER; sibling of cce-lter_zoodb and cce-lter_euphausiids).
  • Grain: one row per (sample × taxon × measurement_type); measurement_value = 0 means imaged-but-absent.
  • Samples: ZooScan-imaged PRPOOS net tows — one per station occupation on CalCOFI lines 80/87/90, 2005-present.
  • Taxa (23): ZooScan machine-classified bioclasses (19 WoRMS-resolved; 4 non-taxonomic operational classes — eggs, multiples, nauplii, others).
  • Measurements: zooscan_abundance (No./m²), zooscan_biomass_carbon (mg C/m²), zooscan_feret_diameter (mm), zooscan_carbon_individual (µg C).
Code
graph LR
  A[zooscan_prpoos.csv<br/>taxon x sample rows] --> B[zooscan_sample<br/>station tows + keys]
  A --> C[zooscan_measurement<br/>long: abundance, biomass, feret, indiv-C]
  A --> D[zooscan_taxon<br/>23 bioclasses + WoRMS]
  C -.sample_id.-> B
  C -.taxon_id.-> D
  B -.ship/cruise/grid.-> E[(shared refs)]

graph LR
  A[zooscan_prpoos.csv<br/>taxon x sample rows] --> B[zooscan_sample<br/>station tows + keys]
  A --> C[zooscan_measurement<br/>long: abundance, biomass, feret, indiv-C]
  A --> D[zooscan_taxon<br/>23 bioclasses + WoRMS]
  C -.sample_id.-> B
  C -.taxon_id.-> D
  B -.ship/cruise/grid.-> E[(shared refs)]

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, 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"))   # overwrite, overwrite_all, dir_data

cc           <- read_calcofi_meta(here("ingest_cce-lter_zooscan.qmd"))
provider     <- cc$provider
dataset      <- cc$dataset
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"))

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")

meas_type_csv <- here("metadata/measurement_type.csv")
d_meas_type   <- read_measurement_type(meas_type_csv)

3 Acquire Source Data

libs/download_zooscan.R scripts the ZooScan portal (public login + PRPOOS plot CGI) for the 23 bioclasses × 2 plot modes and consolidates the embedded per-station CSVs into zooscan_prpoos.csv. It runs once then caches; set overwrite_all = TRUE in libs/ingest.R to re-scrape.

Code
source(here("libs/download_zooscan.R"))
zs_dir <- path_expand(glue("{dir_data}/cce-lter/ZooScan"))
zs_csv <- download_zooscan(zs_dir, overwrite = overwrite_all)
ZooScan: using cached zooscan_prpoos.csv 

4 Read + Clean

The cruise code is YYYYMM + a 2-letter ship code (e.g. 200507NH = Jul 2005, R/V New Horizon). We parse year/month/ship_key from it, build site_key from line + station, and a sample_key per station tow.

Code
zs_dir <- path_expand(glue("{dir_data}/cce-lter/ZooScan"))

# archive source (consolidated + per-taxon extracts) to GCS for provenance
sync_to_gcs(
  local_dir  = zs_dir,
  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(zs_csv)
num <- function(x) suppressWarnings(as.numeric(trimws(as.character(x))))

d <- d_raw |>
  transmute(
    taxon_slug         = taxon,
    cruise_orig        = trimws(cruise),
    line               = num(line),
    station            = num(station),
    latitude           = num(latitude),
    longitude          = num(longitude),
    max_depth_m        = num(max_depth_m),
    min_depth_m        = num(min_depth_m),
    cruise_mid_date    = as.Date(cruise_mid_date),
    station_date       = as.Date(station_date),
    local_time_pst     = trimws(local_time_pst),
    day_night          = trimws(day_night),
    abundance_per_m2   = num(abundance_per_m2),
    biomass_mgC_per_m2 = num(biomass_mgC_per_m2),
    feret_diameter_mm  = num(feret_diameter_mm),
    carbon_content_indiv = num(carbon_content_indiv)) |>
  mutate(
    # ZooScan reports longitude as positive degrees West — flip to negative (°E)
    longitude = -abs(longitude),
    year     = as.integer(str_sub(cruise_orig, 1, 4)),
    month    = as.integer(str_sub(cruise_orig, 5, 6)),
    ship_key = str_sub(cruise_orig, 7),
    site_key = if_else(
      is.na(line) | is.na(station), NA_character_,
      sprintf("%05.1f %05.1f", line, station)),
    datetime_local_pst = suppressWarnings(
      ymd_hm(paste(station_date, local_time_pst), quiet = TRUE)),
    sample_key = paste(cruise_orig, line, station, station_date, sep = "|"))

cat(glue(
  "Read {format(nrow(d), big.mark=',')} (sample x taxon) rows; ",
  "{n_distinct(d$sample_key)} samples; {n_distinct(d$taxon_slug)} taxa; ",
  "lines {paste(sort(unique(d$line)), collapse='/')}; ",
  "{min(d$year)}-{max(d$year)}"), "\n")
Read 34,109 (sample x taxon) rows; 1483 samples; 23 taxa; lines 80/87/90; 2005-2026 

5 Build Taxon Table (WoRMS)

zooscan_taxon is the 23 bioclasses with WoRMS AphiaIDs + classification, resolved offline and cached in metadata/cce-lter/zooscan/taxon_worms.csv (19/23 matched; 4 non-taxonomic operational classes, Q03).

Code
d_worms <- read_csv(here("metadata/cce-lter/zooscan/taxon_worms.csv"))

zooscan_taxon <- d_worms |>
  arrange(taxon_slug) |>
  transmute(
    taxon_id        = row_number(),
    taxon_zooscan, taxon_slug,
    aphia_id        = as.integer(aphia_id),
    scientific_name = scientific_name_accepted,
    rank, taxon_status = status, taxon_note,
    kingdom, phylum, class, order_taxon = order, family)

stopifnot("taxon_slug mismatch source vs WoRMS map" =
            all(unique(d$taxon_slug) %in% zooscan_taxon$taxon_slug))
dbWriteTable(con, "zooscan_taxon", zooscan_taxon, overwrite = TRUE)
cat(glue("zooscan_taxon: {nrow(zooscan_taxon)} bioclasses ",
         "({sum(!is.na(zooscan_taxon$aphia_id))} WoRMS-matched, ",
         "{sum(zooscan_taxon$taxon_status=='non-taxonomic')} non-taxonomic [Q03])"), "\n")
zooscan_taxon: 23 bioclasses (19 WoRMS-matched, 4 non-taxonomic [Q03]) 

6 Build Sample Table + Resolve Keys

One row per distinct station tow. ship_key (the cruise-code suffix) joins the shared ship registry for ship_nodc/ship_name; cruise_key is YYYY-MM-{ship_nodc} validated against the cruise registry.

Code
# position/depth are sample properties, but the per-class plot CGI leaves them
# NA on some taxa's rows (e.g. gelatinous classes) — recover the (identical)
# non-NA value per sample; identity columns are constant within sample_key
d_sample0 <- d |>
  group_by(sample_key) |>
  summarize(
    cruise_orig = first(cruise_orig), year = first(year), month = first(month),
    ship_key = first(ship_key), line = first(line), station = first(station),
    site_key = first(site_key), cruise_mid_date = first(cruise_mid_date),
    station_date = first(station_date), local_time_pst = first(local_time_pst),
    day_night = first(day_night), datetime_local_pst = first(datetime_local_pst),
    latitude    = mean(latitude,    na.rm = TRUE),
    longitude   = mean(longitude,   na.rm = TRUE),
    max_depth_m = mean(max_depth_m, na.rm = TRUE),
    min_depth_m = mean(min_depth_m, na.rm = TRUE),
    .groups = "drop")
stopifnot("sample_key not unique over sample attributes" =
            n_distinct(d_sample0$sample_key) == nrow(d_sample0))

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   691 FALSE   
2 grid     218 TRUE    
3 ship      48 FALSE   
Code
d_ship <- dbGetQuery(con, "SELECT ship_key, ship_nodc, ship_name FROM ship")
valid_ck <- dbGetQuery(con, "SELECT DISTINCT cruise_key FROM cruise")$cruise_key

d_sample <- d_sample0 |>
  left_join(d_ship, by = "ship_key") |>
  mutate(
    cruise_key = if_else(
      is.na(ship_nodc), NA_character_,
      sprintf("%04d-%02d-%s", year, month, ship_nodc)),
    cruise_key = if_else(cruise_key %in% valid_ck, cruise_key, NA_character_)) |>
  arrange(cruise_orig, line, station, station_date) |>
  mutate(sample_id = row_number(), .before = 1)

sample_map     <- d_sample |> select(sample_id, sample_key)
zooscan_sample <- d_sample |>
  select(sample_id, cruise_orig, cruise_key, ship_key, ship_name,
         line, station, site_key, latitude, longitude,
         max_depth_m, min_depth_m, cruise_mid_date, station_date,
         local_time_pst, datetime_local_pst, day_night)
dbWriteTable(con, "zooscan_sample", zooscan_sample, overwrite = TRUE)

n_ship   <- sum(!is.na(d_sample$ship_name))
n_cruise <- sum(!is.na(d_sample$cruise_key))
cat(glue(
  "zooscan_sample: {nrow(zooscan_sample)} samples; ",
  "ship {n_ship}/{nrow(d_sample)} ({round(100*n_ship/nrow(d_sample),1)}%), ",
  "cruise_key {n_cruise}/{nrow(d_sample)} ({round(100*n_cruise/nrow(d_sample),1)}%)"), "\n")
zooscan_sample: 1483 samples; ship 1483/1483 (100%), cruise_key 1043/1483 (70.3%) 

7 Add Spatial

Code
add_point_geom(con, "zooscan_sample", lon_col = "longitude", lat_col = "latitude")
grid_stats <- assign_grid_key(con, "zooscan_sample")
grid_stats |> datatable(caption = "Grid assignment")

8 Pivot Measurements to Long Format

The four ZooScan metrics pivot into long form, joined to sample_id (via sample_key) and taxon_id (via taxon_slug). Explicit zeros (imaged-but- absent) are retained; only NA / non-finite values are dropped.

Code
meas_recode <- c(
  abundance_per_m2     = "zooscan_abundance",
  biomass_mgC_per_m2   = "zooscan_biomass_carbon",
  feret_diameter_mm    = "zooscan_feret_diameter",
  carbon_content_indiv = "zooscan_carbon_individual")

zooscan_measurement <- d |>
  left_join(sample_map, by = "sample_key") |>
  left_join(zooscan_taxon |> select(taxon_id, taxon_slug), by = "taxon_slug") |>
  select(sample_id, taxon_id, all_of(names(meas_recode))) |>
  pivot_longer(cols = all_of(names(meas_recode)),
               names_to = "src_col", values_to = "measurement_value") |>
  filter(!is.na(measurement_value), is.finite(measurement_value)) |>
  mutate(measurement_type = unname(meas_recode[src_col])) |>
  arrange(sample_id, taxon_id, measurement_type) |>
  transmute(measurement_id = row_number(),
            sample_id, taxon_id, measurement_type, measurement_value)

dbWriteTable(con, "zooscan_measurement", zooscan_measurement, overwrite = TRUE)
cat(glue(
  "zooscan_measurement: {format(nrow(zooscan_measurement), big.mark=',')} rows ",
  "({format(sum(zooscan_measurement$measurement_value==0), big.mark=',')} explicit zeros)"), "\n")
zooscan_measurement: 126,692 rows (9,769 explicit zeros) 

9 Add Measurement Types

Code
zs_types <- tibble(
  measurement_type = c("zooscan_abundance", "zooscan_biomass_carbon",
                       "zooscan_feret_diameter", "zooscan_carbon_individual"),
  description = c(
    "Zooplankton areal abundance from ZooScan optical imaging, by image-classified bioclass.",
    "Estimated zooplankton carbon biomass from ZooScan optical imaging, by bioclass.",
    "Mean Feret diameter (organism size) from ZooScan optical imaging, by bioclass.",
    "Mean individual carbon content from ZooScan optical imaging, by bioclass."),
  units = c("count/m2", "mgC/m2", "mm", "ugC"),
  is_canonical = c(TRUE, TRUE, NA, NA),
  `_source_column` = c("abundance_per_m2", "biomass_mgC_per_m2",
                       "feret_diameter_mm", "carbon_content_indiv"),
  `_source_table` = "zooscan_measurement",
  `_source_datasets` = "cce-lter_zooscan",
  `_qual_column` = NA_character_, `_prec_column` = NA_character_)

new_types <- zs_types |> filter(!measurement_type %in% d_meas_type$measurement_type)
if (nrow(new_types) > 0) {
  d_meas_type <- bind_rows(d_meas_type, new_types)
  write_csv(d_meas_type, meas_type_csv, na = "")
  cat(glue("Added measurement type(s): {paste(new_types$measurement_type, collapse=', ')}"), "\n")
} else cat("zooscan measurement types already registered\n")
zooscan measurement types already registered
Code
dbWriteTable(con, "measurement_type", d_meas_type, overwrite = TRUE)

10 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)} dataset(s) registered"), "\n")
dataset: 16 dataset(s) registered 

11 Schema Documentation

Code
zooscan_rels <- list(
  primary_keys = list(
    zooscan_sample      = "sample_id",
    zooscan_measurement = "measurement_id",
    zooscan_taxon       = "taxon_id",
    measurement_type    = "measurement_type"),
  foreign_keys = list(
    list(table = "zooscan_measurement", column = "sample_id",
         ref_table = "zooscan_sample", ref_column = "sample_id"),
    list(table = "zooscan_measurement", column = "taxon_id",
         ref_table = "zooscan_taxon", ref_column = "taxon_id"),
    list(table = "zooscan_measurement", column = "measurement_type",
         ref_table = "measurement_type", ref_column = "measurement_type")))

cc_erd(
  con,
  tables = c("zooscan_sample", "zooscan_measurement", "zooscan_taxon",
             "measurement_type", "dataset"),
  rels = zooscan_rels,
  colors = list(
    lightblue   = c("zooscan_sample", "zooscan_measurement"),
    lightgreen  = "zooscan_taxon",
    lightyellow = "measurement_type",
    white       = "dataset"))

Code
# the SOURCE shape above documents the wrangling; the published tables are the
# consolidated core, so relationships.json is written with the parquet outputs
# below, from core_relationships().

12 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 'zooscan_sample' has 440 NULL values in required column 'cruise_key'
- Table 'zooscan_taxon' has 4 NULL values in required column 'aphia_id' 
Code
# NULLs in cross-dataset keys (cruise_key for cruises absent from the registry)
# are the EXPECTED unmatched remainder (Q02), not a hard failure.
cov <- dbGetQuery(con,
  "SELECT AVG(CASE WHEN ship_key   IS NOT NULL THEN 1 ELSE 0 END) ship,
          AVG(CASE WHEN cruise_key IS NOT NULL THEN 1 ELSE 0 END) cruise,
          AVG(CASE WHEN grid_key   IS NOT NULL THEN 1 ELSE 0 END) grid
   FROM zooscan_sample")
cat(glue("Match coverage: ship_key {round(100*cov$ship,1)}%, ",
         "cruise_key {round(100*cov$cruise,1)}%, grid_key {round(100*cov$grid,1)}%"), "\n")
Match coverage: ship_key 100%, cruise_key 70.3%, grid_key 100% 
Code
orphan <- dbGetQuery(con,
  "SELECT SUM(CASE WHEN s.sample_id IS NULL THEN 1 ELSE 0 END) AS no_sample,
          SUM(CASE WHEN t.taxon_id  IS NULL THEN 1 ELSE 0 END) AS no_taxon
   FROM zooscan_measurement m
   LEFT JOIN zooscan_sample s USING (sample_id)
   LEFT JOIN zooscan_taxon  t USING (taxon_id)")
cat(glue("Orphan measurements: {orphan$no_sample} w/o sample, {orphan$no_taxon} w/o taxon"), "\n")
Orphan measurements: 0 w/o sample, 0 w/o taxon 

13 Data Preview

Code
samp_cols <- dbGetQuery(con,
  "SELECT column_name FROM information_schema.columns
   WHERE table_name='zooscan_sample' AND data_type NOT LIKE 'GEOMETRY%'")$column_name
dbGetQuery(con, glue("SELECT {paste(samp_cols, collapse=', ')} FROM zooscan_sample LIMIT 100")) |>
  datatable(caption = "zooscan_sample — first 100 rows", rownames = FALSE, filter = "top")
Code
dbGetQuery(con,
  "SELECT m.measurement_id, m.sample_id, x.taxon_zooscan, m.measurement_type, m.measurement_value
   FROM zooscan_measurement m JOIN zooscan_taxon x USING (taxon_id)
   ORDER BY m.measurement_id LIMIT 100") |>
  datatable(caption = "zooscan_measurement — first 100 rows", rownames = FALSE)
Code
dbGetQuery(con,
  "SELECT t.taxon_id, t.taxon_zooscan, t.scientific_name, t.aphia_id, t.rank,
          t.taxon_note, COUNT(DISTINCT m.sample_id) AS n_samples
   FROM zooscan_taxon t LEFT JOIN zooscan_measurement m USING (taxon_id)
   GROUP BY ALL ORDER BY t.taxon_id") |>
  datatable(caption = "zooscan_taxon — 23 bioclasses", rownames = FALSE)

14 Emit Core Tables

Project this dataset into the shared consolidated core model (design_env-bio-consolidation.md). These core tables are this ingest’s output: release_database.qmd concatenates the per-dataset shards rather than re-deriving the core from per-dataset tables, so there is exactly one projection to keep correct.

Code
ds_key <- "cce-lter_zooscan"
# filter the crosswalk to THIS dataset -- an unfiltered read leaks other datasets'
# taxa into this shard (the retired emit_core_tables() wrapper filtered internally)
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.

# 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 — a consumer
# joining on worms_id used to match ZERO rows for every seabird and marine
# mammal — 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/ITIS classification (cached in
# metadata/taxon_lineage.csv, so a re-run costs no API calls) and stage it as the
# `taxon` hierarchy build_taxon_reference() reads. Without it a crosswalk- or
# vocabulary-resolved taxon reaches the release with a key and a name and NOTHING
# else — no rank, no parent_taxon_key, no classification — so hierarchy rollups
# ("all Decapoda") silently match nothing and no error is raised anywhere.
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 <- build_dataset_taxon(con,   mt_taxon, tx_over)

append_sample(con, sample_arm_self(
  ds_key, "zooscan_sample", "sample_id", "tow",
  dt_col = "station_date", site_expr = "site_key",
  depth_min = "min_depth_m", depth_max = "max_depth_m"))

append_obs(con, glue("
  SELECT 'bio', '{ds_key}', {ns_key(ds_key, 'tow', 'sp.sample_id')},
         sp.grid_key, sp.cruise_key, sp.latitude, sp.longitude,
         CAST(sp.station_date AS TIMESTAMP), sp.min_depth_m, sp.max_depth_m,
         dt.taxon_key, NULL::VARCHAR, m.measurement_type, m.measurement_value,
         NULL::VARCHAR, NULL::DOUBLE
  FROM zooscan_measurement m JOIN zooscan_sample sp USING (sample_id)
  LEFT JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}'
                            AND dt.ds_taxa_code = CAST(m.taxon_id AS VARCHAR)"))

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)
cat(glue(
  "core projection — sample={core$sample %||% 0} obs={core$obs %||% 0} ",
  "taxon={core$taxon %||% 0} dataset_taxon={core$dataset_taxon %||% 0}\n"))
core projection — sample=1483 obs=126692 taxon=42 dataset_taxon=23
Code
# every measurement must reach obs, and every obs must resolve a
# sampling event; taxon_key coverage is reported (the source vocabulary may
# include entries WoRMS does not resolve)
n_obs <- dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]]
n_exp <- dbGetQuery(con, "
  SELECT COUNT(*) FROM zooscan_measurement m JOIN zooscan_sample sp USING (sample_id)")[[1]]
stopifnot(
  "obs must be one row per measurement" = n_obs == n_exp,
  "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)
n_tax <- dbGetQuery(con, "SELECT COUNT(*) FROM obs WHERE taxon_key IS NOT NULL")[[1]]
cat(glue("obs parity: {format(n_obs, big.mark = ',')} rows; ",
         "{round(100 * n_tax / max(n_obs, 1), 1)}% taxon-resolved"), "\n")
obs parity: 126,692 rows; 100% taxon-resolved 
Code
# serve the retired per-dataset table names as VIEWs over the core, so
# in-notebook consumers and ad-hoc queries keep working against the old names
# (exact for every column the core models, lossy for the rest)
invisible(dbExecute(con, "DROP TABLE IF EXISTS zooscan_measurement_src"))
invisible(dbExecute(con, "ALTER TABLE zooscan_measurement RENAME TO zooscan_measurement_src"))
invisible(dbExecute(con, glue(
  "CREATE OR REPLACE VIEW zooscan_measurement AS
   {compat_measurement_sql(ds_key, 'tow', 'sample_id', 'measurement_id')}")))
cat(glue("compat view zooscan_measurement over obs: ",
         "{dbGetQuery(con, 'SELECT COUNT(*) FROM zooscan_measurement')[[1]]} rows"), "\n")
compat view zooscan_measurement over obs: 126692 rows 

15 Write Parquet Outputs

Code
dir_create(dir_parquet)

mismatches <- list(
  measurement_types = collect_measurement_type_mismatches(
    con, here("metadata/measurement_type.csv")),
  cruise_keys = collect_cruise_key_mismatches(con, "zooscan_sample"))

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_zooscan/relationships.json"
Code
parquet_stats |> mutate(file = basename(path)) |> select(-path) |>
  datatable(caption = "Parquet export statistics")

16 Write Metadata

Code
d_tbls_rd <- read_csv(here("metadata/cce-lter/zooscan/tbls_redefine.csv"))
d_flds_rd <- read_csv(here("metadata/cce-lter/zooscan/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/zooscan/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, 92 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: 32    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 (+20 more)
measurement columns with no units: 25    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 (+13 more)
  backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run

17 Upload to GCS

Code
sync_to_gcs(
  local_dir   = dir_stage,
  sidecar_dir = dir_parquet,
  gcs_prefix = glue("ingest/{dir_label}"),
  bucket     = "calcofi-db")
# A tibble: 5 × 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

18 Questions for Data Providers

Follow-up questions for CCE-LTER (Mark Ohman, Marina Frants), tracked in metadata/cce-lter/zooscan/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 ZooScan data providers (ranked)")

19 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_zooscan 
Code
devtools::session_info()
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.5.2 (2025-10-31)
 os       macOS Sequoia 15.7.1
 system   aarch64, darwin20
 ui       X11
 language (EN)
 collate  en_US.UTF-8
 ctype    en_US.UTF-8
 tz       Europe/Rome
 date     2026-08-14
 pandoc   3.8.3 @ /opt/homebrew/bin/ (via rmarkdown)
 quarto   1.8.25 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 ! package            * version    date (UTC) lib source
   abind                1.4-8      2024-09-12 [1] CRAN (R 4.5.0)
   arrow                24.0.0     2026-04-29 [1] CRAN (R 4.5.2)
   assertthat           0.2.1      2019-03-21 [1] CRAN (R 4.5.0)
   backports            1.5.1      2026-04-03 [1] CRAN (R 4.5.2)
   base64enc            0.1-6      2026-02-02 [1] CRAN (R 4.5.2)
   bit                  4.6.0      2025-03-06 [1] CRAN (R 4.5.0)
   bit64                4.8.2      2026-05-19 [1] CRAN (R 4.5.2)
   blob                 1.3.0      2026-01-14 [1] CRAN (R 4.5.2)
   brio                 1.1.5      2024-04-24 [1] CRAN (R 4.5.0)
   broom                1.0.13     2026-05-14 [1] CRAN (R 4.5.2)
   bslib                0.11.0     2026-05-16 [1] CRAN (R 4.5.2)
   cachem               1.1.0      2024-05-16 [1] CRAN (R 4.5.0)
 P calcofi4db         * 3.16.1     2026-08-14 [?] load_all()
 P calcofi4r          * 1.6.0      2026-08-10 [?] load_all()
   class                7.3-23     2025-01-01 [1] CRAN (R 4.5.2)
   classInt             0.4-11     2025-01-08 [1] CRAN (R 4.5.0)
   cli                  3.6.6      2026-04-09 [1] CRAN (R 4.5.2)
   codetools            0.2-20     2024-03-31 [1] CRAN (R 4.5.2)
   crayon               1.5.3      2024-06-20 [1] CRAN (R 4.5.0)
   crosstalk            1.2.2      2025-08-26 [1] CRAN (R 4.5.0)
   curl                 7.1.0      2026-04-22 [1] CRAN (R 4.5.2)
   data.table           1.18.4     2026-05-06 [1] CRAN (R 4.5.2)
   DBI                * 1.3.0      2026-02-25 [1] CRAN (R 4.5.2)
   dbplyr               2.5.2      2026-02-13 [1] CRAN (R 4.5.2)
   desc                 1.4.3      2023-12-10 [1] CRAN (R 4.5.0)
   devtools             2.5.0      2026-03-14 [1] CRAN (R 4.5.2)
   DiagrammeR           1.0.12     2026-04-27 [1] CRAN (R 4.5.2)
   DiagrammeRsvg        0.1        2016-02-04 [1] CRAN (R 4.5.0)
   digest               0.6.39     2025-11-19 [1] CRAN (R 4.5.2)
   dm                   1.1.2      2026-05-17 [1] CRAN (R 4.5.2)
   dplyr              * 1.2.1      2026-04-03 [1] CRAN (R 4.5.2)
   DT                 * 0.34.0     2025-09-02 [1] CRAN (R 4.5.0)
   duckdb               1.5.2      2026-04-13 [1] CRAN (R 4.5.2)
   dygraphs             1.1.1.6    2018-07-11 [1] CRAN (R 4.5.0)
   e1071                1.7-17     2025-12-18 [1] CRAN (R 4.5.2)
   ellipsis             0.3.2      2021-04-29 [1] CRAN (R 4.5.0)
   evaluate             1.0.5      2025-08-27 [1] CRAN (R 4.5.0)
   farver               2.1.2      2024-05-13 [1] CRAN (R 4.5.0)
   fastmap              1.2.0      2024-05-15 [1] CRAN (R 4.5.0)
   fs                 * 2.1.0      2026-04-18 [1] CRAN (R 4.5.2)
   fuzzyjoin            0.1.8      2026-02-20 [1] CRAN (R 4.5.2)
   gargle               1.6.1      2026-01-29 [1] CRAN (R 4.5.2)
   generics             0.1.4      2025-05-09 [1] CRAN (R 4.5.0)
   geojsonsf            2.0.5      2025-11-26 [1] CRAN (R 4.5.2)
   ggplot2              4.0.3      2026-04-22 [1] CRAN (R 4.5.2)
   glue               * 1.8.1      2026-04-17 [1] CRAN (R 4.5.2)
   googledrive          2.1.2      2025-09-10 [1] CRAN (R 4.5.0)
   gtable               0.3.6      2024-10-25 [1] CRAN (R 4.5.0)
   here               * 1.0.2      2025-09-15 [1] CRAN (R 4.5.0)
   highcharter          0.9.5      2026-04-22 [1] CRAN (R 4.5.2)
   hms                  1.1.4      2025-10-17 [1] CRAN (R 4.5.0)
   htmltools            0.5.9      2025-12-04 [1] CRAN (R 4.5.2)
   htmlwidgets          1.6.4      2023-12-06 [1] CRAN (R 4.5.0)
   httpuv               1.6.17     2026-03-18 [1] CRAN (R 4.5.2)
   httr                 1.4.8      2026-02-13 [1] CRAN (R 4.5.2)
   httr2                1.2.2      2025-12-08 [1] CRAN (R 4.5.2)
   igraph               2.3.2      2026-05-29 [1] CRAN (R 4.5.2)
   isoband              0.3.0      2025-12-07 [1] CRAN (R 4.5.2)
   janitor            * 2.2.1      2024-12-22 [1] CRAN (R 4.5.0)
   jquerylib            0.1.4      2021-04-26 [1] CRAN (R 4.5.0)
   jsonlite           * 2.0.0      2025-03-27 [1] CRAN (R 4.5.0)
   KernSmooth           2.23-26    2025-01-01 [1] CRAN (R 4.5.2)
   knitr              * 1.51       2025-12-20 [1] CRAN (R 4.5.2)
   later                1.4.8      2026-03-05 [1] CRAN (R 4.5.2)
   lattice              0.22-9     2026-02-09 [1] CRAN (R 4.5.2)
   lazyeval             0.2.3      2026-04-04 [1] CRAN (R 4.5.2)
   leafem               0.2.5      2025-08-28 [1] CRAN (R 4.5.0)
   leaflet              2.2.3      2025-09-04 [1] CRAN (R 4.5.0)
   librarian            1.8.1      2021-07-12 [1] CRAN (R 4.5.0)
   lifecycle            1.0.5      2026-01-08 [1] CRAN (R 4.5.2)
   lubridate          * 1.9.5      2026-02-04 [1] CRAN (R 4.5.2)
   magrittr             2.0.5      2026-04-04 [1] CRAN (R 4.5.2)
   mapgl                0.5.0.9000 2026-07-28 [1] Github (bbest/mapgl@484e869)
   mapview              2.11.4     2025-09-08 [1] CRAN (R 4.5.0)
   markdown             2.0        2025-03-23 [1] CRAN (R 4.5.0)
   Matrix               1.7-5      2026-03-21 [1] CRAN (R 4.5.2)
   memoise              2.0.1      2021-11-26 [1] CRAN (R 4.5.0)
   mgcv                 1.9-4      2025-11-07 [1] CRAN (R 4.5.0)
   mime                 0.13       2025-03-17 [1] CRAN (R 4.5.0)
   nlme                 3.1-169    2026-03-27 [1] CRAN (R 4.5.2)
   otel                 0.2.0      2025-08-29 [1] CRAN (R 4.5.0)
   pillar               1.11.1     2025-09-17 [1] CRAN (R 4.5.0)
   pkgbuild             1.4.8      2025-05-26 [1] CRAN (R 4.5.0)
   pkgconfig            2.0.3      2019-09-22 [1] CRAN (R 4.5.0)
   pkgload              1.5.1      2026-04-01 [1] CRAN (R 4.5.2)
   plotly               4.12.0     2026-01-24 [1] CRAN (R 4.5.2)
   png                  0.1-9      2026-03-15 [1] CRAN (R 4.5.2)
   promises             1.5.0      2025-11-01 [1] CRAN (R 4.5.0)
   proxy                0.4-29     2025-12-29 [1] CRAN (R 4.5.2)
   purrr              * 1.2.2      2026-04-10 [1] CRAN (R 4.5.2)
   quantmod             0.4.28     2025-06-19 [1] CRAN (R 4.5.0)
   R6                   2.6.1      2025-02-15 [1] CRAN (R 4.5.0)
   rappdirs             0.3.4      2026-01-17 [1] CRAN (R 4.5.2)
   raster               3.6-32     2025-03-28 [1] CRAN (R 4.5.0)
   RColorBrewer         1.1-3      2022-04-03 [1] CRAN (R 4.5.0)
   Rcpp                 1.1.1-1.1  2026-04-24 [1] CRAN (R 4.5.2)
   readr              * 2.2.0      2026-02-19 [1] CRAN (R 4.5.2)
   rlang                1.2.0      2026-04-06 [1] CRAN (R 4.5.2)
   rlist                0.4.6.2    2021-09-03 [1] CRAN (R 4.5.0)
   rmarkdown            2.31       2026-03-26 [1] CRAN (R 4.5.2)
   rnaturalearth        1.2.0      2026-01-19 [1] CRAN (R 4.5.2)
   rnaturalearthhires   1.0.0.9000 2025-10-02 [1] Github (ropensci/rnaturalearthhires@e4736f6)
   RPostgres            1.4.10     2026-02-16 [1] CRAN (R 4.5.2)
   rprojroot            2.1.1      2025-08-26 [1] CRAN (R 4.5.0)
   rstudioapi           0.18.0     2026-01-16 [1] CRAN (R 4.5.2)
   S7                   0.2.2      2026-04-22 [1] CRAN (R 4.5.2)
   sass                 0.4.10     2025-04-11 [1] CRAN (R 4.5.0)
   satellite            1.0.6      2025-08-21 [1] CRAN (R 4.5.0)
   scales               1.4.0      2025-04-24 [1] CRAN (R 4.5.0)
   sessioninfo          1.2.3      2025-02-05 [1] CRAN (R 4.5.0)
   sf                 * 1.1-1      2026-05-06 [1] CRAN (R 4.5.2)
   shiny                1.14.0     2026-06-21 [1] CRAN (R 4.5.2)
   shinyWidgets         0.9.1      2026-03-09 [1] CRAN (R 4.5.2)
   snakecase            0.11.1     2023-08-27 [1] CRAN (R 4.5.0)
   sp                   2.2-1      2026-02-13 [1] CRAN (R 4.5.2)
   stars                0.7-2      2026-04-03 [1] CRAN (R 4.5.2)
   stringi              1.8.7      2025-03-27 [1] CRAN (R 4.5.0)
   stringr            * 1.6.0      2025-11-04 [1] CRAN (R 4.5.0)
   terra                1.9-34     2026-06-19 [1] CRAN (R 4.5.2)
   testthat           * 3.3.2      2026-01-11 [1] CRAN (R 4.5.2)
   tibble             * 3.3.1      2026-01-11 [1] CRAN (R 4.5.2)
   tidyr              * 1.3.2      2025-12-19 [1] CRAN (R 4.5.2)
   tidyselect           1.2.1      2024-03-11 [1] CRAN (R 4.5.0)
   timechange           0.4.0      2026-01-29 [1] CRAN (R 4.5.2)
   TTR                  0.24.4     2023-11-28 [1] CRAN (R 4.5.0)
   tzdb                 0.5.0      2025-03-15 [1] CRAN (R 4.5.0)
   units              * 1.0-1      2026-03-11 [1] CRAN (R 4.5.2)
   usethis              3.2.1      2025-09-06 [1] CRAN (R 4.5.0)
   utf8                 1.2.6      2025-06-08 [1] CRAN (R 4.5.0)
   uuid                 1.2-2      2026-01-23 [1] CRAN (R 4.5.2)
   V8                   8.2.0      2026-04-21 [1] CRAN (R 4.5.2)
   vctrs                0.7.3      2026-04-11 [1] CRAN (R 4.5.2)
   viridisLite          0.4.3      2026-02-04 [1] CRAN (R 4.5.2)
   visNetwork           2.1.4      2025-09-04 [1] CRAN (R 4.5.0)
   vroom                1.7.1      2026-03-31 [1] CRAN (R 4.5.2)
   withr                3.0.3      2026-06-19 [1] CRAN (R 4.5.2)
   xfun                 0.59       2026-06-19 [1] CRAN (R 4.5.2)
   xtable               1.8-8      2026-02-22 [1] CRAN (R 4.5.2)
   xts                  0.14.2     2026-02-28 [1] CRAN (R 4.5.2)
   yaml                 2.3.12     2025-12-10 [1] CRAN (R 4.5.2)
   zoo                  1.8-15     2025-12-15 [1] CRAN (R 4.5.2)

 [1] /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library

 * ── Packages attached to the search path.
 P ── Loaded and on-disk path mismatch.

──────────────────────────────────────────────────────────────────────────────