CalCOFI.io CalCOFI.io Workflows

Ingest NOAA CalCOFI Database

Published

2026-10-04

1 Overview

Goal: Generate the database from source files with workflow scripts to make updating easier and provenance fully transparent. This allows us to:

  • Rename tables and column names, control data types and use Unicode encoding for a consistent database ingestion strategy, per Database naming conventions in Database – CalCOFI.io Docs.

  • Differentiate between raw and derived or updated tables and columns. For instance, the taxonomy for any given species can change over time, such as lumping or splitting of a given taxa, and by taxonomic authority (e.g., WoRMS, ITIS or GBIF). These taxonomic identifiers and the full taxonomic hierarchy should get regularly updated regardless of source observational data, and can either be updated in the table directly or joined one-to-one with a seperate table in a materialized view (so as not to slow down queries with a regular view).

This workflow processes NOAA CalCOFI database CSV files and outputs parquet files. The workflow:

  1. Reads CSV files from source directory (with GCS archive sync)
  2. Loads into local wrangling DuckDB with transformations
  3. Restructures primary keys (natural keys + sequential IDs)
  4. Creates lookup table and consolidates ichthyo tables
  5. Validates data quality and flags issues
  6. Exports to parquet files (for later integration into Working DuckLake)
Code
graph TD
    A["1\. Read CSV Files<br/>(with GCS archive sync)"] --> B["2\. Load into Wrangling DuckDB<br/>(column renames & type transforms)"]
    B --> C["3\. Restructure Primary Keys<br/>(natural keys + sequential IDs)"]
    C --> D["4\. Create Lookup Table &<br/>Consolidate Ichthyo Tables"]
    D --> E["5\. Validate Data Quality<br/>(corrections, integrity, drop deprecated)"]
    E --> F["6\. Export Parquet Files<br/>(for Working DuckLake)"]

    A:::source
    B:::wrangle
    C:::keys
    D:::consolidate
    E:::validate
    F:::export

    classDef source fill:#e1f5ff,stroke:#0066cc,stroke-width:2px
    classDef wrangle fill:#fff4e1,stroke:#ff9900,stroke-width:2px
    classDef keys fill:#ffe1f5,stroke:#cc0066,stroke-width:2px
    classDef consolidate fill:#e1ffe1,stroke:#00cc66,stroke-width:2px
    classDef validate fill:#f0e1ff,stroke:#6600cc,stroke-width:2px
    classDef export fill:#ffe1e1,stroke:#cc0000,stroke-width:2px
graph TD
    A["1\. Read CSV Files<br/>(with GCS archive sync)"] --> B["2\. Load into Wrangling DuckDB<br/>(column renames & type transforms)"]
    B --> C["3\. Restructure Primary Keys<br/>(natural keys + sequential IDs)"]
    C --> D["4\. Create Lookup Table &<br/>Consolidate Ichthyo Tables"]
    D --> E["5\. Validate Data Quality<br/>(corrections, integrity, drop deprecated)"]
    E --> F["6\. Export Parquet Files<br/>(for Working DuckLake)"]

    A:::source
    B:::wrangle
    C:::keys
    D:::consolidate
    E:::validate
    F:::export

    classDef source fill:#e1f5ff,stroke:#0066cc,stroke-width:2px
    classDef wrangle fill:#fff4e1,stroke:#ff9900,stroke-width:2px
    classDef keys fill:#ffe1f5,stroke:#cc0066,stroke-width:2px
    classDef consolidate fill:#e1ffe1,stroke:#00cc66,stroke-width:2px
    classDef validate fill:#f0e1ff,stroke:#6600cc,stroke-width:2px
    classDef export fill:#ffe1e1,stroke:#cc0000,stroke-width:2px

Overview diagram of CSV ingestion process into the database.

See also 5.3 Ingest datasets with documentation – Database – CalCOFI.io Docs for generic overview of ingestion process.

Code
# devtools::install_local(here::here("../calcofi4db"), force = T)
devtools::load_all(here::here("../calcofi4db"))
devtools::load_all(here::here("../calcofi4r"))
# options(error=NULL)
librarian::shelf(
  CalCOFI / calcofi4db,
  CalCOFI / calcofi4r,
  DBI,
  dplyr,
  DT,
  fs,
  glue,
  gargle,
  googledrive,
  here,
  htmltools,
  janitor,
  jsonlite,
  knitr,
  listviewer,
  litedown,
  lubridate,
  purrr,
  readr,
  rlang,
  sf,
  stringr,
  tibble,
  tidyr,
  units,
  uuid,
  webshot2,
  quiet = T
)
options(readr.show_col_types = F)
options(DT.options = list(scrollX = TRUE))

# common ingest settings (overwrite, dir_data)
source(here("libs/ingest.R"))

# define paths
# provider/dataset/metadata read from this file's authoritative YAML block
cc           <- read_calcofi_meta(here("ingest_swfsc_ichthyo.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"))

if (overwrite) {
  if (file_exists(db_path)) {
    file_delete(db_path)
  }
  # clear stale WAL/tmp from an interrupted run (avoids "WAL checkpoint
  # iteration does not match" errors when reopening)
  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"))
  # keep dir_parquet so write_parquet_outputs can content-hash dedup against
  # the prior run (only changed partitions re-written/uploaded)
}

dir_create(dirname(db_path))
con <- get_duckdb_con(db_path)
load_duckdb_extension(con, "spatial")

# load data using calcofi4db package
# - reads from local Google Drive mount
# - syncs to GCS archive if files changed (creates new timestamped archive)
# - tracks GCS archive path for provenance
d <- read_csv_files(
  provider = provider,
  dataset = dataset,
  dir_data = dir_data,
  sync_archive = TRUE,
  metadata_dir = here("metadata")
) # workflows/metadata/{provider}/{dataset}/

# show source files summary
message(glue("Loaded {nrow(d$source_files)} tables from {d$paths$dir_csv}"))
message(glue("Total rows: {sum(d$source_files$nrow)}"))

2 Check for any mismatched tables and fields

Code
# check data integrity - detects mismatches and controls chunk evaluation
integrity <- check_data_integrity(
  d = d,
  dataset_name = dataset_name,
  halt_on_fail = TRUE
)

# render the pass/fail message
render_integrity_message(integrity)

2.1 ✅ Data Integrity Check Passed: SWFSC Ichthyoplankton

2.1.1 All Systems Go

No mismatches were found between the CSV files and redefinition metadata. The data structures are properly aligned and ready for database ingestion.


3 Show Source Files

Code
show_source_files(d)

4 Show CSV Tables and Fields to Ingest

Code
d$d_csv$tables |>
  datatable(caption = "Tables to ingest.")
Code
d$d_csv$fields |>
  datatable(caption = "Fields to ingest.")

5 Show tables and fields redefined

Code
show_tables_redefine(d)
Code
show_fields_redefine(d)

6 Load Tables into Database

Code
# use ingest_dataset() which handles:
# - transform_data() for applying redefinitions
# - provenance tracking via gcs_path from read_csv_files()
# - automatic uuid column detection
# - ingest_to_working() for each table
tbl_stats <- ingest_dataset(
  con = con,
  d = d,
  mode = if (overwrite) "replace" else "append",
  verbose = TRUE
)

tbl_stats |>
  datatable(rownames = FALSE, filter = "top")

7 Establish Primary Keys

UUID-first approach: Source tables (site, tow, net) retain their *_uuid columns as primary unique identifiers. These UUIDs are minted at sea and remain stable throughout the data lifecycle — even when rows are removed and re-included during QA/QC. Sequential integer IDs would lose this stability because re-sorting or row additions change the assignment. Only cruise uses a natural key (cruise_key) because it has few rows with easily identifiable attributes (ship + year-month). The ichthyo table uses a deterministic UUID v5 hashed from its composite natural key. Other derived tables without source UUIDs (lookup, segment) still use sequential integer IDs for convenience.

7.1 Data Corrections

Apply known data corrections identified by data managers — before create_cruise_key() derives any key, not after. Correction 1 patches a blank ship.ship_nodc (BOLD HORIZON has none in the source shiplookup); until 2026-09 this chunk ran 280 lines after create_cruise_key(), so the July 2019 Bold Horizon cruise was already minted as cruise_key = "2019-07-" (an empty NODC segment — DuckDB’s CONCAT() treats NULL as '') by the time the correction landed, and the key was never re-derived (WS-B / question swfsc_ichthyo_14). create_cruise_key() now also refuses to mint a malformed key outright (calcofi4db ≥ 3.32.0), so a reordering mistake here fails loudly instead of shipping a bad key again. Correction 2 (missing invert species rows) is order-independent — it only appends to species.

Code
# apply data corrections BEFORE create_cruise_key() — see prose above
apply_data_corrections(con, verbose = TRUE)

7.2 Cruises SWFSC moved back to staging (Q09)

SWFSC’s Ed Weber (2026-09-25, swfsc_ichthyo_09): cruises 198202JD and 198212JD (1982-02-31JD, 1982-12-31JD) are partially sorted and “have been moved back to the staging schema”, so they are not part of the source; every other tow without a row is a true zero and stays in the denominator. The 2026-09-26 export (Cruise.csv 694 rows) no longer contains them. The v2026.10.01 release was built from the earlier export and still carried them (1,128 sites, 1,112 tows, 1,145 nets, 1,100 obs rows, all abundance, of which 1,055 on 1982-02 and 45 on 1982-12). This chunk is the explicit exclusion: it stops the render if an export that still holds either cruise is read, rather than letting a partially sorted cruise be counted as zero catch.

Code
# Q09: no row of these cruises may survive into the ingest. They are removed at
# the source (SWFSC), so the check is on what was LOADED, before any key is minted.
q09_cruises <- tibble::tribble(
  ~ship_key, ~date_ym,        ~cruise,
  "JD",      "1982-02-01",    "198202JD",
  "JD",      "1982-12-01",    "198212JD")
q09_in <- paste0("('", q09_cruises$ship_key, "', '", q09_cruises$date_ym, "')", collapse = ", ")
n_q09  <- dbGetQuery(con, glue("
  SELECT COUNT(*) FROM cruise
  WHERE (ship_key, CAST(date_ym AS VARCHAR)) IN ({q09_in})"))[[1]]
stopifnot(
  "cruises 198202JD / 198212JD are in the source export: SWFSC moved them back to staging (swfsc_ichthyo_09, 2026-09-25); re-sync the 2026-09-26 or later export" =
    n_q09 == 0)
cat(glue("Q09: {nrow(q09_cruises)} staging cruises ({paste(q09_cruises$cruise, collapse = ', ')}) absent from the source export"), "\n")
Q09: 2 staging cruises (198202JD, 198212JD) absent from the source export 

7.3 Create cruise_key (natural key)

The cruise_key is a natural key in format YYYY-MM-NODC (4-digit year + 2-digit month + NODC ship code).

Code
# create cruise_key as natural primary key (YYYY-MM-NODC format)
create_cruise_key(
  con,
  cruise_tbl = "cruise",
  ship_tbl = "ship",
  date_col = "date_ym"
)

# verify uniqueness
cruise_keys <- tbl(con, "cruise") |> pull(cruise_key)
if (any(duplicated(cruise_keys))) {
  dups <- cruise_keys[duplicated(cruise_keys)] |> unique() |> head(5)
  stop(glue(
    "cruise_key must be unique — found {sum(duplicated(cruise_keys))} ",
    "duplicates, e.g.: {paste(dups, collapse = ', ')}. ",
    "Try deleting {db_path} and re-rendering."
  ))
}

# show sample cruise keys
tbl(con, "cruise") |>
  select(cruise_uuid, cruise_key, ship_key, date_ym) |>
  head(10) |>
  collect() |>
  datatable(caption = "Sample cruise_key values (YYYY-MM-NODC format)")

7.4 Propagate cruise_key to child tables

Propagate the natural cruise_key to the site table for convenience in queries and sorting. The structural foreign key (site.cruise_uuid → cruise.cruise_uuid) comes from the source data.

Code
# propagate cruise_key from cruise to site (via cruise_uuid)
propagate_natural_key(
  con = con,
  child_tbl = "site",
  parent_tbl = "cruise",
  key_col = "cruise_key",
  join_col = "cruise_uuid"
)

# verify cruise_key is now in site
tbl(con, "site") |>
  select(site_uuid, cruise_uuid, cruise_key, order_occ) |>
  head(10) |>
  collect() |>
  datatable(caption = "Sample site rows with cruise_key")

7.5 Cruise date spans

Every downstream ingest keys its events to a cruise. Until calcofi4db 3.20.0 that was done by the event’s calendar month, and a CalCOFI cruise routinely straddles one (5508BD ran 7 Aug – 25 Sep 1955; 184 of the 664 bottle cruises span two months) — the shorn-off casts landed on the next cruise of the same ship without any FK ever failing (v2026.08.14: 664 bottle cruises released as 799 keys). calcofi4db::resolve_cruise_key() now matches an event to the cruise whose observed date span contains it, and this is where the span comes from: each cruise’s own tows. It rides on the cruise shard, so nothing downstream needs a new input.

Code
d_span <- add_cruise_date_span(
  con,
  event_sql = "SELECT s.cruise_key, t.datetime_start_utc AS datetime
               FROM tow t JOIN site s USING (site_uuid)")

# an overlap between two cruises of one ship would make span matching
# ambiguous; it is a reference-data error, not a tolerance to widen
stopifnot("cruise date spans of one ship overlap" = !any(d_span$overlaps))

cat(glue(
  "{sum(!is.na(d_span$date_min))}/{nrow(d_span)} cruises have a span; ",
  "{sum(d_span$spills_month, na.rm = TRUE)} extend outside their designated ",
  "month — expected, and exactly why the span exists\n"))
674/694 cruises have a span; 245 extend outside their designated month — expected, and exactly why the span exists
Code
d_span |>
  filter(spills_month) |>
  select(cruise_key, date_ym, date_min, date_max, n_events) |>
  datatable(caption = "Cruises whose tows extend outside their designated month")

8 Create Lookup Table

Create unified lookup table from vocabularies for egg stages, larva stages, and tow types.

Code
# egg stage vocabulary (Moser & Ahlstrom, 1985)
egg_stage_vocab <- tibble(
  stage_int = 1:11,
  stage_description = c(
    "egg, stage 1 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 2 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 3 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 4 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 5 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 6 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 7 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 8 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 9 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 10 of 11 (Moser & Ahlstrom, 1985)",
    "egg, stage 11 of 11 (Moser & Ahlstrom, 1985)"
  )
)

# larva stage vocabulary
larva_stage_vocab <- tibble(
  stage_int = 1:5,
  stage_txt = c("YOLK", "PREF", "FLEX", "POST", "TRNS"),
  stage_description = c(
    "larva, yolk sac",
    "larva, preflexion",
    "larva, flexion",
    "larva, postflexion",
    "larva, transformation"
  )
)

# tow type vocabulary (from tow_type table)
tow_type_vocab <- tbl(con, "tow_type") |>
  collect() |>
  mutate(
    lookup_num = row_number(),
    lookup_chr = tow_type_key,
    description = description
  ) |>
  select(lookup_num, lookup_chr, description)

# create unified lookup table
create_lookup_table(
  con = con,
  egg_stage_vocab = egg_stage_vocab,
  larva_stage_vocab = larva_stage_vocab,
  tow_type_vocab = tow_type_vocab
)

# show lookup table
tbl(con, "lookup") |>
  collect() |>
  datatable(caption = "Lookup table with vocabularies")

9 Egg stages 12-15 are screened out (swfsc_ichthyo_02)

The egg stage scale this ingest publishes is Moser & Ahlstrom (1985), stages 1-11. SWFSC (Ed Weber, 2026-09-25): “Stages 12-15 have been used for Dover Sole and sometimes Rex Sole but I do not have a reference. You or I could screen them out if wanted.” Ben Best (2026-10-01): “we can filter out the observations with egg stages 12-15”. So those egg-stage rows leave the stage distribution here, before consolidation, and are written to data/flagged/egg_stage_12_15_screened.csv. The eggs themselves are still counted: the egg abundance (Eggs.csv) is a separate total and is not touched.

Code
d_egg_1215 <- dbGetQuery(con, "
  SELECT e.net_uuid, e.species_id, s.scientific_name, e.stage, e.tally
  FROM egg_stage e LEFT JOIN species s USING (species_id)
  WHERE e.stage > 11 ORDER BY e.species_id, e.stage, e.net_uuid")
dir_create(here("data/flagged"))
write_csv(d_egg_1215, here("data/flagged/egg_stage_12_15_screened.csv"), na = "")
n_before <- dbGetQuery(con, "SELECT COUNT(*) FROM egg_stage")[[1]]
invisible(dbExecute(con, "DELETE FROM egg_stage WHERE stage > 11"))
stopifnot(
  "egg stages must be 1-11 (Moser & Ahlstrom 1985) after screening" =
    dbGetQuery(con, "SELECT COUNT(*) FROM egg_stage WHERE stage NOT BETWEEN 1 AND 11")[[1]] == 0,
  "screening removed exactly the flagged rows" =
    dbGetQuery(con, "SELECT COUNT(*) FROM egg_stage")[[1]] == n_before - nrow(d_egg_1215))
cat(glue("egg stages 12-15 screened out: {format(nrow(d_egg_1215), big.mark = ',')} rows, ",
         "{format(sum(d_egg_1215$tally), big.mark = ',')} eggs, on ",
         "{paste(unique(d_egg_1215$scientific_name), collapse = ', ')}\n"))
egg stages 12-15 screened out: 669 rows, 1,756 eggs, on Sardinops sagax, Engraulis mordax, Trachurus symmetricus, Merluccius productus, Microstomus pacificus
Code
d_egg_1215 |>
  count(species_id, scientific_name, stage, wt = tally, name = "eggs") |>
  datatable(caption = "Egg-stage rows screened out (stages 12-15), eggs by species and stage")

10 Larval size and stage from LarvaeMeasured

The provider’s 2026-09-26 export replaced larvaesize and larvaestage with one table, LarvaeMeasured (larva_measured here): a row per group of measured larvae with a length, a stage or both, keyed to its Larvae count row by CountId, which carries the net and the species (Ed Weber, 2026-09-25, swfsc_ichthyo_04: “you might have to aggregate, for example, if there are fish with multiple lengths but the same stage”). This chunk rebuilds the two distributions the rest of the notebook consumes at their old grain: larva_size sums tally by (net, species, length), larva_stage by (net, species, stage). The joint length x stage the new table carries is not used (it was never in the source before).

A measured row whose CountId names no Larvae row has no net and no species, so it cannot be placed; it is written to data/flagged/orphan_larva_measured.csv and left out.

Code
# every larva count row is one (net, species): the join below must not fan out
stopifnot(
  "Larvae.CountId must be unique" =
    dbGetQuery(con, "SELECT COUNT(*) - COUNT(DISTINCT count_uuid) FROM larva")[[1]] == 0,
  "Larvae (net, species) must be unique: one count row per taxon per net" =
    dbGetQuery(con, "SELECT COUNT(*) FROM (SELECT net_uuid, species_id FROM larva
                     GROUP BY ALL HAVING COUNT(*) > 1)")[[1]] == 0)

# measured rows that reach no count row: no net, no species
d_lm_orphan <- dbGetQuery(con, "
  SELECT m.larva_measured_uuid, m.count_uuid, m.length_mm, m.stage, m.tally
  FROM larva_measured m ANTI JOIN larva l USING (count_uuid)")
dir_create(here("data/flagged"))
write_csv(d_lm_orphan, here("data/flagged/orphan_larva_measured.csv"), na = "")

for (sql in c(
  "CREATE OR REPLACE TABLE larva_size AS
   SELECT l.net_uuid, l.species_id, m.length_mm, CAST(SUM(m.tally) AS INTEGER) AS tally
   FROM larva_measured m JOIN larva l USING (count_uuid)
   WHERE m.length_mm IS NOT NULL
   GROUP BY ALL",
  "CREATE OR REPLACE TABLE larva_stage AS
   SELECT l.net_uuid, l.species_id, m.stage, CAST(SUM(m.tally) AS INTEGER) AS tally
   FROM larva_measured m JOIN larva l USING (count_uuid)
   WHERE m.stage IS NOT NULL
   GROUP BY ALL"))
  invisible(dbExecute(con, sql))

# the two distributions account for every placeable measured row: nothing is
# dropped by the aggregation except rows with neither a length nor a stage
n_lm <- dbGetQuery(con, "
  SELECT COUNT(*) AS n,
         COUNT(*) FILTER (WHERE length_mm IS NOT NULL) AS n_len,
         COUNT(*) FILTER (WHERE stage     IS NOT NULL) AS n_stage,
         COUNT(*) FILTER (WHERE length_mm IS NULL AND stage IS NULL) AS n_neither,
         SUM(tally) FILTER (WHERE length_mm IS NOT NULL) AS tally_len,
         SUM(tally) FILTER (WHERE stage     IS NOT NULL) AS tally_stage
  FROM larva_measured m SEMI JOIN larva l USING (count_uuid)")
stopifnot(
  "larva_size must carry every placeable measured tally with a length" =
    dbGetQuery(con, "SELECT SUM(tally) FROM larva_size")[[1]] == n_lm$tally_len,
  "larva_stage must carry every placeable measured tally with a stage" =
    dbGetQuery(con, "SELECT SUM(tally) FROM larva_stage")[[1]] == n_lm$tally_stage,
  "every larval stage must be in the YOLK/PREF/FLEX/POST/TRNS vocabulary" =
    all(dbGetQuery(con, "SELECT DISTINCT stage FROM larva_stage")$stage %in%
          larva_stage_vocab$stage_txt))

cat(glue(
  "larva_measured: {format(nrow(d_lm_orphan) + n_lm$n, big.mark = ',')} rows; ",
  "{format(nrow(d_lm_orphan), big.mark = ',')} orphan (CountId in no Larvae row, ",
  "tally {format(sum(d_lm_orphan$tally), big.mark = ',')}, flagged); ",
  "{format(n_lm$n_neither, big.mark = ',')} with neither length nor stage\n",
  "larva_size:  {format(dbGetQuery(con, 'SELECT COUNT(*) FROM larva_size')[[1]], big.mark = ',')} ",
  "(net, species, length) rows\n",
  "larva_stage: {format(dbGetQuery(con, 'SELECT COUNT(*) FROM larva_stage')[[1]], big.mark = ',')} ",
  "(net, species, stage) rows\n"))
larva_measured: 345,738 rows; 974 orphan (CountId in no Larvae row, tally 2,320, flagged); 0 with neither length nor stage
larva_size:  241,328 (net, species, length) rows
larva_stage: 107,521 (net, species, stage) rows

11 Consolidate to Tidy Ichthyo Table

Consolidate 5 ichthyoplankton tables (egg, eggstage, larva, larvastage, larvasize) into a single tidy table.

Code
erDiagram
    egg {
        uuid net_uuid FK
        int  species_id FK
        int  tally
    }
    egg_stage {
        uuid net_uuid FK
        int  species_id FK
        int  stage
        int  tally
    }
    larva {
        uuid net_uuid FK
        int  species_id FK
        int  tally
    }
    larva_stage {
        uuid net_uuid FK
        int  species_id FK
        str  stage
        int  tally
    }
    larva_size {
        uuid net_uuid FK
        int  species_id FK
        dbl  length_mm
        int  tally
    }
    ichthyo {
        uuid ichthyo_uuid PK
        uuid net_uuid FK
        int species_id FK
        str life_stage
        str measurement_type
        dbl measurement_value
        int tally
    }
    lookup {
        int lookup_id PK
        str category
        int lookup_num
        str lookup_chr
        str description
    }
    egg ||--|{ ichthyo : "life_stage = egg"
    egg_stage ||--|{ ichthyo : "life_stage = egg, type = stage"
    larva ||--|{ ichthyo : "life_stage = larva"
    larva_stage ||--|{ ichthyo : "life_stage = larva, type = stage"
    larva_size ||--|{ ichthyo : "life_stage = larva, type = size"
    ichthyo }o--|| lookup : "measurement_value"
erDiagram
    egg {
        uuid net_uuid FK
        int  species_id FK
        int  tally
    }
    egg_stage {
        uuid net_uuid FK
        int  species_id FK
        int  stage
        int  tally
    }
    larva {
        uuid net_uuid FK
        int  species_id FK
        int  tally
    }
    larva_stage {
        uuid net_uuid FK
        int  species_id FK
        str  stage
        int  tally
    }
    larva_size {
        uuid net_uuid FK
        int  species_id FK
        dbl  length_mm
        int  tally
    }
    ichthyo {
        uuid ichthyo_uuid PK
        uuid net_uuid FK
        int species_id FK
        str life_stage
        str measurement_type
        dbl measurement_value
        int tally
    }
    lookup {
        int lookup_id PK
        str category
        int lookup_num
        str lookup_chr
        str description
    }
    egg ||--|{ ichthyo : "life_stage = egg"
    egg_stage ||--|{ ichthyo : "life_stage = egg, type = stage"
    larva ||--|{ ichthyo : "life_stage = larva"
    larva_stage ||--|{ ichthyo : "life_stage = larva, type = stage"
    larva_size ||--|{ ichthyo : "life_stage = larva, type = size"
    ichthyo }o--|| lookup : "measurement_value"

Consolidate 5 ichthyoplankton tables into a single tidy ichthyo table with lookup.

Code
message("Consolidating ichthyoplankton tables...")

# consolidate all ichthyo tables (keeps net_uuid as FK to net table)
consolidate_ichthyo_tables(
  con = con,
  output_tbl = "ichthyo",
  larva_stage_vocab = larva_stage_vocab
)

# consolidate_ichthyo_tables() skips a source table that does not exist; after the
# 2026-09-26 export dropped larvaesize/larvaestage, that would have shipped no
# size or stage rows without an error. Every one of the six parts must be present.
d_parts <- dbGetQuery(con, "
  SELECT DISTINCT life_stage || '/' || COALESCE(measurement_type, '') AS part FROM ichthyo")
stopifnot(
  "ichthyo must hold all six parts (egg, egg stage, larva, larva stage, larva size, invert)" =
    setequal(d_parts$part,
             c("egg/", "egg/stage", "larva/", "larva/stage", "larva/size", "invert/")))

# assign ichthyo_uuid — deterministic UUID v5 from composite natural key
assign_deterministic_uuids(
  con = con,
  table_name = "ichthyo",
  id_col = "ichthyo_uuid",
  key_cols = c(
    "net_uuid",
    "species_id",
    "life_stage",
    "measurement_type",
    "measurement_value"
  )
)

# show sample rows
tbl(con, "ichthyo") |>
  head(20) |>
  collect() |>
  datatable(caption = "Sample ichthyo table rows (tidy format)")
Code
# summarize ichthyo table
ichthyo_summary <- tbl(con, "ichthyo") |>
  group_by(life_stage, measurement_type) |>
  summarize(
    n_rows = n(),
    n_species = n_distinct(species_id),
    total_tally = sum(tally, na.rm = TRUE),
    .groups = "drop"
  ) |>
  collect()

ichthyo_summary |>
  arrange(life_stage, measurement_type) |>
  datatable(
    caption = HTML(mark(
      "The `ichthyo` table summary by life_stage and measurement_type"
    ))
  ) |>
  formatCurrency(
    columns = c("n_rows", "n_species", "total_tally"),
    currency = "",
    digits = 0
  )

12 Data Quality Improvements

This section validates referential integrity. Data corrections (§ “Establish Primary Keys” above) now run before create_cruise_key(), not here — see that section for why.

12.1 Validate Referential Integrity

Run validation checks and flag invalid rows for review.

Code
# ensure flagged directory exists
dir_flagged <- here("data/flagged")
if (!dir.exists(dir_flagged)) {
  dir.create(dir_flagged, recursive = TRUE)
}

# validate egg stages (must be 1-11)
invalid_egg_stages <- validate_egg_stages(con, "egg_stage", "stage")
invalid_egg_stages_csv <- file.path(dir_flagged, "invalid_egg_stages.csv")
invalid_egg_stages_desc <- "Egg stage values NOT 1 to 11 (ie, not in Moser & Ahlstrom 1985 vocab)"
if (nrow(invalid_egg_stages) > 0) {
  flag_invalid_rows(
    invalid_rows = invalid_egg_stages,
    output_path = invalid_egg_stages_csv,
    description = invalid_egg_stages_desc
  )
} else if (file.exists(invalid_egg_stages_csv)) {
  # stages 12-15 are screened out above (screen_egg_stages); a sidecar from an
  # earlier run would claim flagged rows that no longer exist
  file.remove(invalid_egg_stages_csv)
}
[1] TRUE
Code
show_flagged_file(
  invalid_egg_stages,
  invalid_egg_stages_csv,
  invalid_egg_stages_desc
)

Egg stage values NOT 1 to 11 (ie, not in Moser & Ahlstrom 1985 vocab): No invalid rows found ✓

Code
# define validation checks
validations <- list(
  list(
    type = "fk",
    data_tbl = "ichthyo",
    col = "species_id",
    ref_tbl = "species",
    ref_col = "species_id",
    output_file = "orphan_species.csv",
    description = "Species IDs not found in species table"
  ),
  list(
    type = "fk",
    data_tbl = "ichthyo",
    col = "net_uuid",
    ref_tbl = "net",
    ref_col = "net_uuid",
    output_file = "orphan_nets.csv",
    description = "Net UUIDs not found in net table"
  )
)

# run validations
validation_results <- validate_dataset(
  con = con,
  validations = validations,
  output_dir = dir_flagged
)

# show validation summary with GitHub links
show_validation_results(validation_results)
Code
# optionally delete flagged rows (dry run first)
if (validation_results$total_flagged > 0) {
  message(glue("Found {validation_results$total_flagged} invalid rows"))

  # dry run to see what would be deleted
  delete_stats <- delete_flagged_rows(
    con = con,
    validation_results = validation_results,
    dry_run = TRUE
  )

  delete_stats |> datatable(caption = "Rows to be deleted (dry run)")

  # uncomment to actually delete:
  delete_flagged_rows(con, validation_results, dry_run = FALSE)
}

12.2 Drop Deprecated Tables

The source tables have been consolidated into ichthyo (tidy format) and lookup (vocabularies). Drop these before creating the schema diagram.

Note: *_uuid columns are retained as primary unique identifiers (see rationale in “Establish Primary Keys” above).

Code
# tables consolidated into ichthyo
deprecated_ichthyo <- c(
  "egg",
  "egg_stage",
  "larva",
  "larva_stage",
  "larva_size",
  "larva_measured"
)

# tables consolidated into lookup
deprecated_lookup <- c("tow_type")

# tables of the 2026-09-26 export that are loaded (so the integrity check sees
# every source column) but NOT ingested; each needs a design decision first:
# - cufes: CUFES is its own ingest (ingest_swfsc_cufes.qmd, from this export's Cufes.csv); switching
#   its source to this export is a change to that notebook
# - species_itis / species_worms: the provider's ITIS/WoRMS names and lineage
#   (swfsc_ichthyo_05/13); emit_core reads SpeciesWormsLookup from the source to
#   check the keys, so the wrangling copies are not needed
# - preservative: a lookup nothing references in this export
# `fish` (grown-out larvae, Ben 2026-10-04) is kept: published as its own table in
# emit_core.
not_ingested <- c("cufes", "species_itis", "species_worms", "preservative")

# all deprecated tables
deprecated_tables <- c(deprecated_ichthyo, deprecated_lookup, not_ingested)

# ShipLookup.ShipIces -> ship.ship_ices, beside ship_nodc (swfsc_ichthyo_14; Ben,
# 2026-10-01: "carry the ICES code ... as its own column on the ship and cruise
# tables, beside the NODC code"). cruise_key stays YYYY-MM-NODC: ship_nodc is
# untouched and still keys every cruise. The release copies it onto `cruise`.
# Every SWFSC ship carries one; where it differs from the NODC code it is reported.
d_ices <- dbGetQuery(con, "
  SELECT ship_key, ship_name, ship_nodc, ship_ices FROM ship
  WHERE ship_ices IS DISTINCT FROM ship_nodc ORDER BY 1")
stopifnot(
  "every SWFSC ship carries an ICES code (swfsc_ichthyo_14)" =
    dbGetQuery(con, "SELECT COUNT(*) FROM ship WHERE ship_ices IS NULL")[[1]] == 0)
d_ices |>
  datatable(caption = "Ships whose ICES code differs from the NODC code (cruise_key keeps NODC)")
Code
# drop each deprecated table
for (tbl in deprecated_tables) {
  if (tbl %in% DBI::dbListTables(con)) {
    DBI::dbExecute(con, glue("DROP TABLE {tbl}"))
    message(glue("Dropped deprecated table: {tbl}"))
  }
}

message(glue(
  "\nRemaining tables: {paste(sort(DBI::dbListTables(con)), collapse = ', ')}"
))

13 Standardize Taxonomy

Update species table with WoRMS/ITIS/GBIF identifiers using local lookups against spp.duckdb (MarineSensitivity species DB). Falls back to WoRMS API only for species not found locally. Then build taxonomy hierarchy via recursive CTEs.

Code
# MarineSensitivity species DB for local taxonomy lookups
spp_db_path <- Sys.getenv(
  "SPP_DB_PATH",
  unset = "/Users/bbest/_big/msens/derived/spp.duckdb"
)

sp_results <- standardize_species_local(
  con = con,
  spp_db_path = spp_db_path,
  overwrite = overwrite
)

sp_results |>
  datatable(caption = "Species standardization results")
Code
taxon_rows <- build_taxon_hierarchy(
  con = con,
  spp_db_path = spp_db_path,
  overwrite = overwrite
)

# show taxon stats
if (nrow(taxon_rows) > 0) {
  taxon_rows |>
    count(authority, taxonRank) |>
    arrange(authority, taxonRank) |>
    datatable(caption = "Taxon hierarchy by authority and rank")
}
Code
# show taxa_rank lookup
dbReadTable(con, "taxa_rank") |>
  datatable(caption = "Taxa rank ordering")

13.1 Taxonomy Statistics

Code
n_species <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM species")$n
n_worms <- dbGetQuery(
  con,
  "SELECT COUNT(*) AS n FROM species WHERE worms_id IS NOT NULL"
)$n
n_itis <- dbGetQuery(
  con,
  "SELECT COUNT(*) AS n FROM species WHERE itis_id IS NOT NULL"
)$n
n_gbif <- dbGetQuery(
  con,
  "SELECT COUNT(*) AS n FROM species WHERE gbif_id IS NOT NULL"
)$n
n_taxon <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM taxon")$n

tibble(
  metric = c(
    "total species",
    "with worms_id",
    "with itis_id",
    "with gbif_id",
    "taxon hierarchy rows"
  ),
  count = c(n_species, n_worms, n_itis, n_gbif, n_taxon)
) |>
  datatable(caption = "Taxonomy standardization summary")

14 Schema Documentation

Code
tbls <- dbListTables(con) |> sort()
cc_erd(con, tables = tbls)

14.1 Primary Key Strategy

UUID-first: Source tables use UUIDs minted at sea as primary identifiers. Only cruise uses a natural key because it has few, easily identifiable rows. Derived tables (lookup, segment) use sequential integer IDs since they have no source UUID. The ichthyo table uses a deterministic UUID v5 hashed from its composite natural key.

Table Primary Key Type
cruise cruise_key Natural key (YYYY-MM-NODC); also retains cruise_uuid from source
ship ship_key Natural key (2-letter)
site site_uuid Source UUID (minted at sea)
tow tow_uuid Source UUID (minted at sea)
net net_uuid Source UUID (minted at sea)
species species_id Natural from source
ichthyo ichthyo_uuid Deterministic UUID v5 (from net_uuid, species_id, life_stage, measurement_type, measurement_value)
lookup lookup_id Sequential (derived table, sorted by lookup_type, lookup_num)
taxon (authority, taxonID) Compound natural key (authority + ID within authority); ERD uses taxonID only
taxa_rank taxonRank Natural key (rank name string)

14.2 Foreign Key Relationships

DuckDB doesn’t support ALTER TABLE ADD FOREIGN KEY. We define relationships as lists for visualization and relationships.json export.

Foreign Key Relationships:

ship.ship_key (PK)
    ↓
cruise.cruise_key (PK)  ←──  cruise.ship_key (FK)
    ↓                        cruise.cruise_uuid (unique, source)
site.site_uuid (PK)     ←──  site.cruise_uuid (FK → cruise.cruise_uuid)
    ↓                        site.cruise_key (denormalized, for queries)
tow.tow_uuid (PK)       ←──  tow.site_uuid (FK → site.site_uuid)
    ↓                        tow.tow_type_key → lookup (lookup_type='tow_type')
net.net_uuid (PK)        ←──  net.tow_uuid (FK → tow.tow_uuid)
    ↓
ichthyo.ichthyo_uuid (PK) ←──  ichthyo.net_uuid (FK → net.net_uuid)
                               ichthyo.species_id (FK) → species.species_id

taxon.(authority, taxonID) (PK)  ←──  taxon.taxonRank (FK) → taxa_rank.taxonRank
taxa_rank.taxonRank (PK)
                                      species.worms_id ··> taxon.taxonID (logical, WHERE authority='WoRMS')
                                      species.itis_id  ··> taxon.taxonID (logical, WHERE authority='ITIS')
Code
# define PK/FK relationships for visualization and relationships.json
# uses UUID PKs for source tables, sequential IDs for derived tables
ichthyo_rels <- list(
  primary_keys = list(
    cruise = "cruise_key",
    ship = "ship_key",
    site = "site_uuid",
    tow = "tow_uuid",
    net = "net_uuid",
    species = "species_id",
    ichthyo = "ichthyo_uuid",
    lookup = "lookup_id",
    taxon = "taxonID",
    taxa_rank = "taxonRank",
    grid = "grid_key",
    segment = "segment_id"
  ),
  foreign_keys = list(
    list(
      table = "ichthyo",
      column = "net_uuid",
      ref_table = "net",
      ref_column = "net_uuid"
    ),
    list(
      table = "ichthyo",
      column = "species_id",
      ref_table = "species",
      ref_column = "species_id"
    ),
    list(
      table = "net",
      column = "tow_uuid",
      ref_table = "tow",
      ref_column = "tow_uuid"
    ),
    list(
      table = "tow",
      column = "site_uuid",
      ref_table = "site",
      ref_column = "site_uuid"
    ),
    list(
      table = "site",
      column = "cruise_key",
      ref_table = "cruise",
      ref_column = "cruise_key"
    ),
    list(
      table = "cruise",
      column = "ship_key",
      ref_table = "ship",
      ref_column = "ship_key"
    ),
    list(
      table = "taxon",
      column = "taxonRank",
      ref_table = "taxa_rank",
      ref_column = "taxonRank"
    ),
    list(
      table = "segment",
      column = "cruise_key",
      ref_table = "cruise",
      ref_column = "cruise_key"
    ),
    list(
      table = "segment",
      column = "site_uuid_beg",
      ref_table = "site",
      ref_column = "site_uuid"
    ),
    list(
      table = "invert",
      column = "net_uuid",
      ref_table = "net",
      ref_column = "net_uuid"
    ),
    list(
      table = "invert",
      column = "species_id",
      ref_table = "species",
      ref_column = "species_id"
    )
  )
)

cc_erd(con, rels = ichthyo_rels)

15 Add Spatial

15.1 Add site.geom

Code
# add geometry column using DuckDB spatial
# note: DuckDB spatial doesn't track SRID metadata (unlike PostGIS)
# all geometries assumed WGS84 (EPSG:4326) by convention
add_point_geom(con, "site", lon_col = "longitude", lat_col = "latitude")

15.2 Fix calcofi4r grid

Problems with calcofi4r::cc_grid:

  • uses old station (line, position) vs newer site (line, station)
  • sta_lin, sta_pos: integer, so drops necessary decimal that is found in site_key
  • sta_lin == 90, sta_pos == 120 repeats for:
    • sta_pattern == ‘historical’ (sta_dpos == 20); and
    • sta_pattern == ‘standard’ (sta_dpos == 10)
Code
librarian::shelf(
  calcofi4r,
  mapview,
  quiet = T
)

cc_grid_v2 <- calcofi4r::cc_grid |>
  # handle bundled data that may still have sta_key (renamed to site_key)
  rename(any_of(c(site_key = "sta_key"))) |>
  select(
    site_key,
    shore = sta_shore,
    pattern = sta_pattern,
    spacing = sta_dpos
  ) |>
  separate_wider_delim(
    site_key,
    ",",
    names = c("line", "station"),
    cols_remove = F
  ) |>
  mutate(
    line = as.double(line),
    station = as.double(station),
    grid_key = ifelse(
      pattern == "historical",
      glue("st{station}-ln{line}_hist"),
      glue("st{station}-ln{line}")
    ),
    zone = glue("{shore}-{pattern}")
  ) |>
  relocate(grid_key, station) |>
  st_as_sf() |>
  mutate(
    area_km2 = st_area(geom) |>
      set_units(km^2) |>
      as.numeric()
  )

cc_grid_ctrs_v2 <- calcofi4r::cc_grid_ctrs |>
  rename(any_of(c(site_key = "sta_key"))) |>
  select(site_key, pattern = sta_pattern) |>
  left_join(
    cc_grid_v2 |>
      st_drop_geometry(),
    by = c("site_key", "pattern")
  ) |>
  select(-site_key) |>
  relocate(grid_key)

cc_grid_v2 <- cc_grid_v2 |>
  select(-site_key)

cc_grid_v2 |>
  st_drop_geometry() |>
  datatable()
Code
mapview(cc_grid_v2, zcol = "zone") +
  mapview(cc_grid_ctrs_v2, cex = 1)
Code
grid <- cc_grid_v2 |>
  as.data.frame() |>
  left_join(
    cc_grid_ctrs_v2 |>
      as.data.frame() |>
      select(grid_key, geom_ctr = geom),
    by = "grid_key"
  ) |>
  st_as_sf(sf_column_name = "geom")

# convert sf geometry to WKB for DuckDB
grid_df <- grid |>
  mutate(
    geom_wkb = sf::st_as_binary(geom, hex = TRUE),
    geom_ctr_wkb = sf::st_as_binary(geom_ctr, hex = TRUE)
  ) |>
  sf::st_drop_geometry() |>
  select(-geom_ctr)

# write to DuckDB
dbWriteTable(con, "grid", grid_df, overwrite = TRUE)

# convert WKB to native GEOMETRY (requires storage_compatibility_version = 'latest')
dbExecute(con, "ALTER TABLE grid ADD COLUMN IF NOT EXISTS geom GEOMETRY")
[1] 0
Code
dbExecute(con, "UPDATE grid SET geom = ST_GeomFromHEXWKB(geom_wkb)")
[1] 225
Code
dbExecute(con, "ALTER TABLE grid DROP COLUMN geom_wkb")
[1] 0
Code
dbExecute(con, "ALTER TABLE grid ADD COLUMN IF NOT EXISTS geom_ctr GEOMETRY")
[1] 0
Code
dbExecute(con, "UPDATE grid SET geom_ctr = ST_GeomFromHEXWKB(geom_ctr_wkb)")
[1] 225
Code
dbExecute(con, "ALTER TABLE grid DROP COLUMN geom_ctr_wkb")
[1] 0
Code
message("Grid table created with geometry columns")

15.3 Update site.grid_key

Code
grid_stats <- assign_grid_key(con, "site")
grid_stats |> datatable()
Code
# add standardized site_key (NNN.N NNN.N format)
standardize_site_key(con, "site", "line", "station")

15.4 Add segment: line segments between consecutive sites

Code
# use SQL to avoid GEOMETRY column type issue with tbl()
segment <- tbl(
  con,
  sql(
    "SELECT cruise_key, order_occ, site_uuid, longitude AS lon, latitude AS lat
   FROM site"
  )
) |>
  left_join(
    tbl(con, "tow") |>
      select(site_uuid, datetime_start_utc),
    by = "site_uuid"
  ) |>
  group_by(
    cruise_key,
    order_occ,
    site_uuid,
    lon,
    lat
  ) |>
  summarize(
    time_beg = min(datetime_start_utc, na.rm = T),
    time_end = max(datetime_start_utc, na.rm = T),
    .groups = "drop"
  ) |>
  collect()

segment <- segment |>
  arrange(cruise_key, order_occ, time_beg) |>
  group_by(cruise_key) |>
  mutate(
    site_uuid_beg = lag(site_uuid),
    lon_beg = lag(lon),
    lat_beg = lag(lat),
    time_beg = lag(time_beg)
  ) |>
  ungroup() |>
  filter(!is.na(lon_beg), !is.na(lat_beg)) |>
  mutate(
    m = pmap(
      list(lon_beg, lat_beg, lon, lat),
      \(x1, y1, x2, y2) {
        matrix(c(x1, y1, x2, y2), nrow = 2, byrow = T)
      }
    ),
    geom = map(m, st_linestring)
  ) |>
  select(
    cruise_key,
    site_uuid_beg,
    site_uuid_end = site_uuid,
    lon_beg,
    lat_beg,
    lon_end = lon,
    lat_end = lat,
    time_beg,
    time_end,
    geom
  ) |>
  st_as_sf(
    sf_column_name = "geom",
    crs = 4326
  ) |>
  mutate(
    time_hr = as.numeric(difftime(time_end, time_beg, units = "hours")),
    length_km = st_length(geom) |>
      set_units(km) |>
      as.numeric(),
    km_per_hr = length_km / time_hr
  )

# convert to WKB and write to DuckDB
segment_df <- segment |>
  mutate(geom_wkb = sf::st_as_binary(geom, hex = TRUE)) |>
  sf::st_drop_geometry()

dbWriteTable(con, "segment", segment_df, overwrite = TRUE)

dbExecute(con, "ALTER TABLE segment ADD COLUMN IF NOT EXISTS geom GEOMETRY")
[1] 0
Code
dbExecute(con, "UPDATE segment SET geom = ST_GeomFromHEXWKB(geom_wkb)")
[1] 59287
Code
dbExecute(con, "ALTER TABLE segment DROP COLUMN geom_wkb")
[1] 0
Code
# assign segment_id sorted by time_beg
assign_sequential_ids(
  con = con,
  table_name = "segment",
  id_col = "segment_id",
  sort_cols = c("time_beg")
)

message("Segment table created")
Code
# slowish, so use cached figure
map_segment_png <- here(glue("figures/{provider}_{dataset}_segment_map.png"))

if (!file_exists(map_segment_png)) {
  # exclude native GEOMETRY column (unsupported by duckdb R driver);
  # use ST_AsText to convert to WKT for sf
  seg_cols <- dbGetQuery(
    con,
    "SELECT column_name FROM information_schema.columns
     WHERE table_name = 'segment' AND data_type != 'GEOMETRY'"
  )$column_name
  seg_sql <- paste(
    "SELECT",
    paste(seg_cols, collapse = ", "),
    ", ST_AsText(geom) AS geom_wkt FROM segment"
  )
  segment_sf <- dbGetQuery(con, seg_sql) |>
    st_as_sf(wkt = "geom_wkt", crs = 4326) |>
    select(-geom_wkt) |>
    mutate(year = year(time_beg))

  m <- mapView(segment_sf, zcol = "year")
  mapshot2(m, file = map_segment_png)
}

htmltools::img(
  src = map_segment_png |> str_replace(here(), "."),
  width = "600px"
)

16 Report

Code
# cc_erd handles GEOMETRY columns natively (unlike dm_from_con)
cc_erd(con, rels = ichthyo_rels)

Code
# use sql() to avoid GEOMETRY column type issue with tbl()
d_eff <- tbl(
  con,
  sql(
    "SELECT segment_id, cruise_key, time_beg, time_hr, length_km FROM segment"
  )
) |>
  mutate(
    year = year(time_beg)
  ) |>
  group_by(year) |>
  summarize(
    time_hr = sum(time_hr, na.rm = T),
    length_km = sum(length_km, na.rm = T)
  ) |>
  collect()

total_hours <- sum(d_eff$time_hr, na.rm = T)
total_km <- sum(d_eff$length_km, na.rm = T)

fmt <- function(x, ...) format(x, big.mark = ",", ...)
message(glue(
  "Total effort: {fmt(round(total_hours))} hours ({fmt(round(total_hours/24))} days, {fmt(round(total_hours/24/365, 1))} years)"
))
message(glue("Total distance: {fmt(round(total_km))} km"))

17 Load Dataset Metadata

Code
# dataset registry built from authoritative ingest_*.qmd YAML (was dataset.csv)
d_dataset <- ingest_yaml_to_dataset_df(read_ingest_yaml(here()))
dbWriteTable(con, "dataset", d_dataset, overwrite = TRUE)
message(glue("dataset: {nrow(d_dataset)} datasets registered"))

18 Questions for Data Providers

Open questions tracked in metadata/swfsc/ichthyo/questions.csv, surfaced here so they travel with the workflow rather than living in someone’s inbox. The blocker is quantified by this notebook’s own flagged-data sidecars: orphan_species.csv excludes 316,316 specimens across 17 unresolvable species_id values, so every abundance total in the release is low by that amount until they resolve.

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 SWFSC ichthyoplankton data providers (ranked)")

19 Validate Local Database

Validate data quality in the local wrangling database before exporting to parquet. The parquet outputs from this workflow can later be used to update the Working DuckLake.

Code
# validate data quality
validation <- validate_for_release(con)

if (validation$passed) {
  message("Validation passed!")
  if (nrow(validation$checks) > 0) {
    validation$checks |>
      filter(status != "pass") |>
      datatable(caption = "Validation Warnings")
  }
} else {
  cat("Validation FAILED:\n")
  cat(paste("-", validation$errors, collapse = "\n"))
}
Validation FAILED:
- Table 'site' has 2084 NULL values in required column 'grid_key'
- Table 'species' has 182 NULL values in required column 'gbif_id'

20 Enforce Column Types

Force integer/smallint types on columns that R’s numeric mapped to DOUBLE during dbWriteTable(). Uses flds_redefine.csv (type_new) as the source of truth for source-table columns, plus explicit overrides for derived-table columns.

Code
type_changes <- enforce_column_types(
  con = con,
  d_flds_rd = d$d_flds_rd,
  type_overrides = list(
    ichthyo.ichthyo_uuid = "UUID",
    ichthyo.net_uuid = "UUID",
    ichthyo.species_id = "SMALLINT",
    ichthyo.tally = "INTEGER",
    lookup.lookup_id = "INTEGER",
    lookup.lookup_num = "INTEGER",
    segment.segment_id = "INTEGER",
    segment.site_uuid_beg = "UUID",
    segment.site_uuid_end = "UUID",
    species.worms_id = "INTEGER",
    species.itis_id = "INTEGER",
    species.gbif_id = "INTEGER",
    taxon.taxonID = "INTEGER",
    taxon.acceptedNameUsageID = "INTEGER",
    taxon.parentNameUsageID = "INTEGER",
    taxa_rank.rank_order = "SMALLINT"
  ),
  tables = dbListTables(con),
  verbose = TRUE
)

if (nrow(type_changes) > 0) {
  type_changes |>
    datatable(caption = "Column type changes applied")
}

21 Data Preview

Preview first and last rows of each table before writing parquet outputs.

Code
preview_tables(
  con,
  c(
    "cruise",
    "ship",
    "site",
    "tow",
    "net",
    "species",
    "taxon",
    "taxa_rank",
    "ichthyo",
    "lookup",
    "grid",
    "segment"
  )
)

21.1 cruise (694 rows)

21.2 ship (48 rows)

21.3 site (59,976 rows)

21.4 tow (74,394 rows)

21.5 net (77,888 rows)

21.6 species (1,167 rows)

21.7 taxon (3,412 rows)

21.8 taxa_rank (54 rows)

21.9 ichthyo (854,735 rows)

21.10 lookup (26 rows)

21.11 grid (225 rows)

21.12 segment (59,287 rows)

22 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, so there is exactly one projection to keep correct.

Ichthyo is the deepest hierarchy in the model — site -> tow -> net — which sample carries as an adjacency list (parent_sample_key / root_sample_key), with site_key, order_occ, grid_key and cruise_key inherited down from the site and the net gear code on tow_type. Net effort (volume filtered, standard haul factor, …) becomes sample_measurement; the larval size/stage distributions become obs_attribute under the abundance headline in obs.

Code
ds_key <- "swfsc_ichthyo"
# 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 (compat_event_sql / ns_key / prune_taxon_shard), so this is a declaration.
#
# taxa: 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 CalCOFI species code with `taxon_key` empty and the ids the SOURCE
# supplied (worms_id / itis_id / gbif_id) carried along as `ds_source_json`;
# resolve_dataset_taxon() fills the key from the authorities. ds_prefix is
# "calcofi", not the dataset_key: `species` is the shared CalCOFI species list
# (ichthyo + the folded invert export), so its ds_taxon_key is `calcofi:<id>`.
# There is no ichthyo arm inside calcofi4db any more — a column renamed or
# dropped here now fails at this call rather than becoming NA at release.
#
# AphiaID (swfsc_ichthyo_05/_13; Ed Weber 2026-09-25 "aggregate by AphiaID", Ben
# Best 2026-10-01 "aggregate taxa by AphiaID ... Bill's taxonomy stands"): the ids
# declared are the SOURCE's own Species.AphiaId / Species.Tsn, verbatim -- not the
# species table's, which standardize_species_local() above re-points to the
# accepted id from spp.duckdb (so ds_source_json said "source" about an id the
# source never gave). ensure_taxon_xref() then keys each code by the ACCEPTED id
# of its AphiaID (a key must be accepted; the source id stays in ds_source_json),
# and the source name stays verbatim as ds_scientific_name, the display name.
# Codes that share an AphiaID share a taxon_key; obs / obs_attribute below sum them.
d_src_species <- d$d_csv$data$data[[which(d$d_csv$data$tbl == "Species")]] |>
  transmute(ds_taxa_code = as.character(SppCode),
            worms_src = as.integer(AphiaId), itis_src = as.integer(Tsn))
d_vocab <- dbGetQuery(con, "
  SELECT CAST(species_id AS VARCHAR) AS ds_taxa_code,
         scientific_name AS ds_scientific_name,
         common_name     AS ds_common_name,
         worms_id, itis_id, gbif_id
  FROM species ORDER BY species_id") |>
  left_join(d_src_species, by = "ds_taxa_code") |>
  mutate(worms_id = coalesce(worms_src, as.integer(worms_id)),
         itis_id  = coalesce(itis_src,  as.integer(itis_id))) |>
  select(-worms_src, -itis_src)
stopifnot(
  "every source species code is in the vocabulary" =
    all(d_src_species$ds_taxa_code %in% d_vocab$ds_taxa_code),
  "every source species code carries its own AphiaID" = !anyNA(d_src_species$worms_src))
n_staged <- append_dataset_taxon(con, ds_key, d_vocab, ds_prefix = "calcofi")

# build_taxon_reference() reads the WoRMS lineage `taxon` table built above
# (build_taxon_hierarchy()) as the authority for rank/parent/classification, then
# OVERWRITES `taxon` with the unified shape. prune_taxon_shard() trims it back to
# the transitive parent closure of this dataset's vocabulary — the hierarchy is
# broader than the taxa these observations reach, and ancestors must survive
# because descendant expansion walks parent_taxon_key.

# 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 <- resolve_dataset_taxon(con, mt_taxon, tx_over)
n_pruned   <- prune_taxon_shard(con, ds_key)

# the ingest asserts its own crosswalk (D6): every species code the observations
# use is in the vocabulary, every staged row keys an authority id, every Aves
# taxon keys itis:. Halts the render on any finding.
obs_codes <- dbGetQuery(con,
  "SELECT DISTINCT CAST(species_id AS VARCHAR) AS c FROM ichthyo
   WHERE species_id IS NOT NULL")$c
chk_taxon <- check_dataset_taxon(con, ds_key, codes = obs_codes)
cat(glue("vocabulary: {n_staged} CalCOFI species codes staged; ",
         "taxon {n_taxon} (pruned to {n_pruned$taxon}), ",
         "dataset_taxon {n_ds_taxon}"), "\n")
vocabulary: 1167 CalCOFI species codes staged; taxon 3415 (pruned to 1686), dataset_taxon 1167 
Code
# the provider's SpeciesWormsLookup ("their accepted taxon names", not updated in a
# year) against the key each code resolved to: a code keyed to another id than its
# source AphiaID (WoRMS has superseded it), or a lookup name WoRMS no longer uses,
# is listed in data/flagged/species_worms_lookup_check.csv for the provider
d_wl <- d$d_csv$data$data[[which(d$d_csv$data$tbl == "SpeciesWormsLookup")]] |>
  transmute(worms_src = as.integer(AphiaId), lookup_name = Taxon, lookup_rank = taxon_rank)
d_taxkey <- dbGetQuery(con, glue("
  SELECT d.ds_taxa_code, d.ds_scientific_name, d.taxon_key,
         t.scientific_name AS key_name, t.rank AS key_rank
  FROM dataset_taxon d LEFT JOIN taxon t USING (taxon_key)
  WHERE d.dataset_key = '{ds_key}'")) |>
  left_join(d_src_species, by = "ds_taxa_code") |>
  left_join(d_wl, by = "worms_src") |>
  mutate(rekeyed     = taxon_key != paste0("worms:", worms_src),
         lookup_stale = !is.na(lookup_name) & lookup_name != key_name)
d_taxkey_flag <- d_taxkey |>
  filter(rekeyed | lookup_stale | is.na(lookup_name)) |>
  select(ds_taxa_code, ds_scientific_name, worms_src, lookup_name, lookup_rank,
         taxon_key, key_name, key_rank, rekeyed, lookup_stale) |>
  arrange(as.integer(ds_taxa_code))
write_csv(d_taxkey_flag, here("data/flagged/species_worms_lookup_check.csv"), na = "")
d_shared <- d_taxkey |> group_by(taxon_key, key_name) |>
  summarize(n_codes = n(), codes = paste(sort(as.integer(ds_taxa_code)), collapse = ", "),
            source_names = paste(unique(ds_scientific_name), collapse = "; "), .groups = "drop") |>
  filter(n_codes > 1) |> arrange(desc(n_codes), taxon_key)
stopifnot(
  "every code's source AphiaID is in SpeciesWormsLookup" = all(!is.na(d_taxkey$lookup_name)))
cat(glue("AphiaID: {nrow(d_taxkey)} codes; {sum(d_taxkey$rekeyed)} keyed to the accepted id ",
         "of a superseded source AphiaID; {sum(d_taxkey$lookup_stale)} lookup names differ from ",
         "WoRMS's for the key; {nrow(d_shared)} taxon_keys shared by ",
         "{sum(d_shared$n_codes)} codes (their counts are summed below)\n"))
AphiaID: 1167 codes; 11 keyed to the accepted id of a superseded source AphiaID; 9 lookup names differ from WoRMS's for the key; 13 taxon_keys shared by 31 codes (their counts are summed below)
Code
d_shared |>
  datatable(caption = "Species codes that share one taxon_key (one AphiaID): observations summed")
Code
d_taxkey_flag |>
  datatable(caption = "Codes keyed to another id than their source AphiaID, or whose SpeciesWormsLookup name WoRMS no longer uses")
Code
# sample — three chained levels. `site` has no datetime of its own, so it takes
# the earliest tow time; `tow` and `net` inherit site_key/order_occ/grid_key/
# cruise_key from the site, and both carry the net gear code as tow_type.
# 16th column data_stage (ichthyo does not distinguish one, so NULL); 17th
# source_uuid — the provider's own site_uuid/tow_uuid/net_uuid, verbatim
# (calcofi4db >= 3.32.0, WS-B/Ed Weber's ask). Every other ingest's arm stays
# 15-column and gets source_uuid = NULL for free (append_sample()'s arity
# dispatch), so this is additive.
append_sample(con, glue("
  SELECT {ns_key(ds_key, 'site', 's.site_uuid')} AS sample_key, 'site' AS sample_type,
         NULL::VARCHAR AS parent_sample_key,
         {ns_key(ds_key, 'site', 's.site_uuid')} AS root_sample_key,
         '{ds_key}' AS dataset_key,
         s.grid_key, s.site_key, s.cruise_key, CAST(s.order_occ AS INTEGER) AS order_occ,
         s.latitude, s.longitude,
         CAST(td.dt AS TIMESTAMP) AS datetime,
         NULL::DOUBLE AS depth_min_m, NULL::DOUBLE AS depth_max_m,
         NULL::VARCHAR AS tow_type,
         NULL::VARCHAR AS data_stage,
         s.site_uuid AS source_uuid
  FROM site s
  LEFT JOIN (SELECT site_uuid, min(datetime_start_utc) AS dt FROM tow GROUP BY 1) td
         ON td.site_uuid = s.site_uuid"))
# depth_max_m of a tow and of its nets: Net.NetDepth, the MAXIMUM POSSIBLE depth of
# the net in m (nominal, not a measured depth; not wire-out) -- swfsc_ichthyo_08,
# and Ben's reading 2026-10-04 (swfsc_ichthyo_19, proposed to Ed). Where the sea
# floor is shallower the net did not reach it: 210 of 59,976 stations of the
# 2026-09-26 export put it more than 10 m below the deepest GEBCO cell around them.
# It is published as is (never clipped), and the release reports those stations in
# their own line of the seafloor check (check_depth_vs_seafloor(nominal_datasets =)).
# A net column but a property of the tow: the tow takes it only where every net of
# the tow agrees; a tow with no NetDepth (the surface Manta tows) keeps NULL.
d_netdepth <- dbGetQuery(con, "
  SELECT t.tow_type_key,
         COUNT(*)                                        AS n_tow,
         COUNT(*) FILTER (WHERE nd.n_depth = 1)          AS n_tow_depth,
         COUNT(*) FILTER (WHERE nd.n_depth > 1)          AS n_tow_disagree,
         MIN(nd.depth_max_m)                             AS depth_min,
         MAX(nd.depth_max_m)                             AS depth_max
  FROM tow t
  LEFT JOIN (SELECT tow_uuid, COUNT(DISTINCT net_depth_m) AS n_depth,
                    MAX(net_depth_m) AS depth_max_m
             FROM net GROUP BY 1) nd USING (tow_uuid)
  GROUP BY 1 ORDER BY 2 DESC")
stopifnot(
  "every net of a tow must report one NetDepth (swfsc_ichthyo_08)" =
    sum(d_netdepth$n_tow_disagree) == 0,
  "NetDepth must be a positive depth in m (never NaN, never <= 0)" =
    dbGetQuery(con, "SELECT COUNT(*) FROM net WHERE net_depth_m IS NOT NULL
                     AND (isnan(net_depth_m) OR net_depth_m <= 0)")[[1]] == 0)
d_netdepth |>
  datatable(caption = "Net.NetDepth (maximum possible net depth) -> sample.depth_max_m of tows and nets, by tow type")
Code
append_sample(con, glue("
  SELECT {ns_key(ds_key, 'tow', 't.tow_uuid')} AS sample_key, 'tow' AS sample_type,
         {ns_key(ds_key, 'site', 't.site_uuid')} AS parent_sample_key,
         {ns_key(ds_key, 'site', 't.site_uuid')} AS root_sample_key,
         '{ds_key}' AS dataset_key, s.grid_key, s.site_key, s.cruise_key,
         CAST(s.order_occ AS INTEGER) AS order_occ, s.latitude, s.longitude,
         CAST(t.datetime_start_utc AS TIMESTAMP) AS datetime,
         0::DOUBLE AS depth_min_m, CAST(nd.depth_max_m AS DOUBLE) AS depth_max_m,
         t.tow_type_key AS tow_type,
         NULL::VARCHAR AS data_stage,
         t.tow_uuid AS source_uuid
  FROM tow t JOIN site s USING (site_uuid)
  LEFT JOIN (SELECT tow_uuid, MAX(net_depth_m) AS depth_max_m
             FROM net GROUP BY 1) nd USING (tow_uuid)"))
append_sample(con, glue("
  SELECT {ns_key(ds_key, 'net', 'n.net_uuid')} AS sample_key, 'net' AS sample_type,
         {ns_key(ds_key, 'tow', 'n.tow_uuid')} AS parent_sample_key,
         {ns_key(ds_key, 'site', 't.site_uuid')} AS root_sample_key,
         '{ds_key}' AS dataset_key, s.grid_key, s.site_key, s.cruise_key,
         CAST(s.order_occ AS INTEGER) AS order_occ, s.latitude, s.longitude,
         CAST(t.datetime_start_utc AS TIMESTAMP) AS datetime,
         0::DOUBLE AS depth_min_m, CAST(n.net_depth_m AS DOUBLE) AS depth_max_m,
         t.tow_type_key AS tow_type,
         NULL::VARCHAR AS data_stage,
         n.net_uuid AS source_uuid
  FROM net n JOIN tow t USING (tow_uuid) JOIN site s USING (site_uuid)"))

# the NetDepth projection landed on exactly the tows and nets that carry one
d_depth <- dbGetQuery(con, "
  SELECT sample_type, COUNT(*) AS n, COUNT(depth_max_m) AS n_depth_max
  FROM sample GROUP BY 1 ORDER BY 1")
stopifnot(
  "every tow with a NetDepth carries it as depth_max_m" =
    d_depth$n_depth_max[d_depth$sample_type == "tow"] == sum(d_netdepth$n_tow_depth),
  "every net with a NetDepth carries it as depth_max_m" =
    d_depth$n_depth_max[d_depth$sample_type == "net"] ==
    dbGetQuery(con, "SELECT COUNT(net_depth_m) FROM net")[[1]],
  "a site has no depth of its own" =
    d_depth$n_depth_max[d_depth$sample_type == "site"] == 0)
cat(glue("depth_max_m from NetDepth: ",
         "{paste(sprintf('%s %s of %s', d_depth$sample_type, format(d_depth$n_depth_max, big.mark=','), format(d_depth$n, big.mark=',')), collapse = '; ')}"), "\n")
depth_max_m from NetDepth: net 65,128 of 77,888; site      0 of 59,976; tow 61,625 of 74,394 
Code
# obs — the abundance headline: BASE rows only (measurement_type IS NULL in the
# source long table). taxon_key resolves through dataset_taxon on species_id —
# the global worms:/itis: key, not the dataset-local species_id. Aggregated by
# AphiaID (swfsc_ichthyo_04/_05/_13): codes that share a taxon_key in one net are
# SUMMED into one row, so (sample_key, taxon_key, life_stage, measurement_type) is
# unique -- e.g. 683 Sebastes + 3023 S. crocotulus (both AphiaID 126175), and 788
# S. leptorhynchus + 792 S. californiensis (both 275218). Each code keeps its own
# dataset_taxon row and verbatim name; only the counts merge.
append_obs(con, glue("
  SELECT 'bio', '{ds_key}', {ns_key(ds_key, 'net', 'i.net_uuid')},
         s.grid_key, s.cruise_key, s.latitude, s.longitude,
         CAST(t.datetime_start_utc AS TIMESTAMP), NULL::DOUBLE, NULL::DOUBLE,
         dt.taxon_key, i.life_stage,
         'abundance', CAST(SUM(i.tally) AS DOUBLE), NULL::VARCHAR, NULL::DOUBLE
  FROM ichthyo i JOIN net n USING (net_uuid) JOIN tow t USING (tow_uuid)
                 JOIN site s USING (site_uuid)
  LEFT JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}'
                            AND dt.ds_taxa_code = CAST(i.species_id AS VARCHAR)
  WHERE i.measurement_type IS NULL
  GROUP BY i.net_uuid, s.grid_key, s.cruise_key, s.latitude, s.longitude,
           t.datetime_start_utc, dt.taxon_key, i.life_stage"))

# obs_attribute — the size and stage distributions UNDER that headline. `size`
# takes the canonical name `body_length`; a `stage` bin also carries the human
# label from `lookup`. Summed by AphiaID exactly as obs (and, for larvae, already
# summed over the LarvaeMeasured rows of one length or one stage).
append_obs_attribute(con, glue("
  SELECT '{ds_key}', {ns_key(ds_key, 'net', 'i.net_uuid')},
         dt.taxon_key, i.life_stage,
         CASE i.measurement_type WHEN 'size' THEN 'body_length' ELSE i.measurement_type END,
         i.measurement_value,
         CASE WHEN i.measurement_type = 'stage' THEN lk.description ELSE NULL END,
         CAST(SUM(i.tally) AS INTEGER), NULL::VARCHAR
  FROM ichthyo i
  LEFT JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}'
                            AND dt.ds_taxa_code = CAST(i.species_id AS VARCHAR)
  LEFT JOIN lookup lk ON lk.lookup_type = i.life_stage || '_stage'
                     AND lk.lookup_num = CAST(i.measurement_value AS INTEGER)
  WHERE i.measurement_type IN ('stage','size')
  GROUP BY i.net_uuid, dt.taxon_key, i.life_stage, i.measurement_type,
           i.measurement_value, lk.description"))

# the published keys are unique (Ed Weber 2026-09-25: "you will have to aggregate");
# v2026.10.01 had 3 duplicated obs keys, the 2026-09-26 export 25 before this sum
n_obs_dup <- dbGetQuery(con, "
  SELECT COUNT(*) FROM (SELECT sample_key, taxon_key, life_stage, measurement_type
                        FROM obs GROUP BY ALL HAVING COUNT(*) > 1)")[[1]]
n_attr_dup <- dbGetQuery(con, "
  SELECT COUNT(*) FROM (SELECT sample_key, taxon_key, life_stage, measurement_type, bin_value
                        FROM obs_attribute GROUP BY ALL HAVING COUNT(*) > 1)")[[1]]
n_rows_src <- dbGetQuery(con, "
  SELECT COUNT(*) FROM ichthyo i JOIN net n USING (net_uuid) JOIN tow t USING (tow_uuid)
                  JOIN site s USING (site_uuid) WHERE i.measurement_type IS NULL")[[1]]
stopifnot(
  "obs (sample_key, taxon_key, life_stage, measurement_type) must be unique" = n_obs_dup == 0,
  "obs_attribute (sample_key, taxon_key, life_stage, measurement_type, bin_value) must be unique" =
    n_attr_dup == 0,
  "every obs row has a taxon_key (else unresolved codes would be summed together)" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs WHERE taxon_key IS NULL")[[1]] == 0,
  "summing by AphiaID keeps every individual" =
    dbGetQuery(con, "SELECT SUM(measurement_value) FROM obs")[[1]] ==
    dbGetQuery(con, "SELECT SUM(i.tally) FROM ichthyo i JOIN net n USING (net_uuid)
                     JOIN tow t USING (tow_uuid) JOIN site s USING (site_uuid)
                     WHERE i.measurement_type IS NULL")[[1]])
n_obs_now <- dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]]
cat(glue("AphiaID aggregation: {format(n_rows_src, big.mark = ',')} source count rows -> ",
         "{format(n_obs_now, big.mark = ',')} obs rows ({n_rows_src - n_obs_now} summed into a ",
         "row of the same net and AphiaID)\n"))
AphiaID aggregation: 487,641 source count rows -> 487,616 obs rows (25 summed into a row of the same net and AphiaID)
Code
# fish — SWFSC's Fish table (Ed Weber 2026-09-26: "the Fish (i.e., grown out of
# larvae size) table"; Ben 2026-10-04: wanted, in lower snake_case). Counts per net
# and species code of fish past the larval stage. Published as its own table at
# the source grain -- (sample_key of the net, species_id), with the taxon_key the
# code resolves to -- and NOT projected into obs: no life_stage in the vocabulary
# says "grown out of larvae" exactly (juvenile, or juvenile and adult?), and 2,923
# of its net x species pairs also have a Larvae count, so whether a fish is also in
# that count must be known before an abundance could be summed (swfsc_ichthyo_20).
d_fish_orphan <- dbGetQuery(con, "
  SELECT COUNT(*) FILTER (WHERE n.net_uuid IS NULL) AS no_net,
         COUNT(*) FILTER (WHERE dt.taxon_key IS NULL) AS no_taxon
  FROM fish f LEFT JOIN net n USING (net_uuid)
  LEFT JOIN dataset_taxon dt ON dt.dataset_key = 'swfsc_ichthyo'
                            AND dt.ds_taxa_code = CAST(f.species_id AS VARCHAR)")
stopifnot(
  "every fish row names a released net" = d_fish_orphan$no_net == 0,
  "every fish species code resolves to a taxon_key" = d_fish_orphan$no_taxon == 0,
  "fish (net, species code) is unique" =
    dbGetQuery(con, "SELECT COUNT(*) FROM (SELECT net_uuid, species_id FROM fish
                     GROUP BY ALL HAVING COUNT(*) > 1)")[[1]] == 0,
  "a fish tally is a positive count" =
    dbGetQuery(con, "SELECT COUNT(*) FROM fish WHERE tally IS NULL OR tally <= 0")[[1]] == 0)
invisible(dbExecute(con, glue("
  CREATE OR REPLACE TABLE fish_pub AS
  SELECT {ns_key(ds_key, 'net', 'f.net_uuid')} AS sample_key,
         CAST(f.species_id AS INTEGER) AS species_id, dt.taxon_key,
         CAST(f.tally AS INTEGER) AS tally
  FROM fish f
  LEFT JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}'
                            AND dt.ds_taxa_code = CAST(f.species_id AS VARCHAR)
  ORDER BY sample_key, species_id")))
invisible(dbExecute(con, "DROP TABLE fish"))
invisible(dbExecute(con, "ALTER TABLE fish_pub RENAME TO fish"))
d_fish <- dbGetQuery(con, "
  SELECT COUNT(*) AS n, COUNT(DISTINCT sample_key) AS nets, COUNT(DISTINCT species_id) AS codes,
         SUM(tally) AS fish FROM fish")
cat(glue("fish: {format(d_fish$n, big.mark = ',')} rows, {format(d_fish$fish, big.mark = ',')} fish ",
         "on {format(d_fish$nets, big.mark = ',')} nets, {d_fish$codes} species codes\n"))
fish: 10,796 rows, 18,789 fish on 7,179 nets, 240 species codes
Code
# net effort, as SWFSC gives it (swfsc_ichthyo_03; Ed Weber 2026-09-26: "you should
# trust us on the standard haul factor instead of calculating based on angles for
# every 10-m of wire"). Nothing here recomputes it: std_haul_factor is Net.Shf,
# volume_sampled Net.Vol, prop_sorted Net.PropSorted, verbatim. "They cannot be
# missing": a net without one stops the render (a question back to SWFSC, never a
# silent drop). A proportion sorted outside (0, 1] is reported, not changed.
d_effort <- dbGetQuery(con, "
  SELECT COUNT(*) AS n_net,
         COUNT(*) FILTER (WHERE volume_sampled IS NULL)       AS no_volume,
         COUNT(*) FILTER (WHERE standard_haul_factor IS NULL) AS no_shf,
         COUNT(*) FILTER (WHERE prop_sorted IS NULL)          AS no_prop_sorted,
         COUNT(*) FILTER (WHERE volume_sampled <= 0 OR standard_haul_factor <= 0) AS not_positive,
         COUNT(*) FILTER (WHERE prop_sorted <= 0 OR prop_sorted > 1) AS prop_sorted_out
  FROM net")
stopifnot(
  "net effort cannot be missing (swfsc_ichthyo_03): volume, standard haul factor, proportion sorted" =
    d_effort$no_volume + d_effort$no_shf + d_effort$no_prop_sorted == 0,
  "volume sampled and standard haul factor are positive" = d_effort$not_positive == 0)
d_prop_out <- dbGetQuery(con, "
  SELECT n.net_uuid, t.tow_type_key, s.cruise_key, s.site_key, n.prop_sorted,
         n.volume_sampled, n.standard_haul_factor
  FROM net n JOIN tow t USING (tow_uuid) JOIN site s USING (site_uuid)
  WHERE n.prop_sorted <= 0 OR n.prop_sorted > 1")

# true zeros (swfsc_ichthyo_09; Ed Weber: "The others are true zeros and have valid
# positive zooplankton volumes"). obs stays positive-only by design: a tow with no
# egg, larva or invertebrate row is a ZERO for every taxon, implied by its `sample`
# rows and their effort, which every tow and net keeps (the denominator). Only the
# two staging cruises were removed (exclude_staging_cruises); nothing marks a tow
# "unsorted". Zooplankton volume is measured on the oblique tows (C1, CB) only.
d_zero <- dbGetQuery(con, "
  WITH caught AS (SELECT DISTINCT n.tow_uuid FROM ichthyo i JOIN net n USING (net_uuid)),
  z AS (
    SELECT t.tow_uuid, t.tow_type_key, COUNT(n.net_uuid) AS n_net,
           MIN(n.volume_sampled) AS vol_min, MAX(n.totalplankton) AS tp_max
    FROM tow t LEFT JOIN net n USING (tow_uuid)
    WHERE t.tow_uuid NOT IN (SELECT tow_uuid FROM caught)
    GROUP BY 1, 2)
  SELECT tow_type_key, COUNT(*) AS n_zero_tow,
         COUNT(*) FILTER (WHERE n_net = 0)                     AS no_net,
         COUNT(*) FILTER (WHERE n_net > 0 AND vol_min > 0)    AS volume_positive,
         COUNT(*) FILTER (WHERE n_net > 0 AND tp_max > 0)     AS zooplankton_positive,
         COUNT(*) FILTER (WHERE n_net > 0 AND tp_max IS NULL) AS zooplankton_missing
  FROM z GROUP BY 1 ORDER BY 1")
stopifnot(
  "every zero-catch tow that has a net has a positive volume sampled (true zero, in the denominator)" =
    all(d_zero$volume_positive == d_zero$n_zero_tow - d_zero$no_net))
d_zero |>
  datatable(caption = "Zero-catch tows (no egg, larva or invertebrate row): true zeros, by tow type")
Code
cat(glue(
  "true zeros: {format(sum(d_zero$n_zero_tow), big.mark = ',')} of ",
  "{format(dbGetQuery(con, 'SELECT COUNT(*) FROM tow')[[1]], big.mark = ',')} tows caught nothing; ",
  "all {format(sum(d_zero$volume_positive), big.mark = ',')} with a net have a positive volume; ",
  "{sum(d_zero$no_net)} tow(s) have no net at all (no effort: not in any density denominator); ",
  "oblique (C1/CB) zero tows with no zooplankton volume: ",
  "{sum(d_zero$zooplankton_missing[d_zero$tow_type_key %in% c('C1', 'CB')])}\n",
  "net effort: {format(d_effort$n_net, big.mark = ',')} nets, none missing; ",
  "{nrow(d_prop_out)} with prop_sorted outside (0, 1]\n"))
true zeros: 6,302 of 74,394 tows caught nothing; all 6,293 with a net have a positive volume; 9 tow(s) have no net at all (no effort: not in any density denominator); oblique (C1/CB) zero tows with no zooplankton volume: 5
net effort: 77,888 nets, none missing; 2 with prop_sorted outside (0, 1]
Code
if (nrow(d_prop_out)) d_prop_out |> datatable(caption = "Nets whose proportion sorted is outside (0, 1] (reported to SWFSC)")
Code
# sample_measurement — the five net-level effort quantities, long-formatted
append_sample_measurement(con, glue("
  SELECT {ns_key(ds_key, 'net', 'net_uuid')}, '{ds_key}',
         mt, mv, NULL::VARCHAR
  FROM (
    SELECT net_uuid, 'volume_sampled' mt, volume_sampled mv FROM net UNION ALL
    SELECT net_uuid, 'std_haul_factor', standard_haul_factor FROM net UNION ALL
    SELECT net_uuid, 'prop_sorted', prop_sorted FROM net UNION ALL
    SELECT net_uuid, 'small_plankton_biomass', smallplankton FROM net UNION ALL
    SELECT net_uuid, 'total_plankton_biomass', totalplankton FROM net)
  WHERE mv IS NOT NULL"))

core <- list(
  sample             = dbGetQuery(con, "SELECT COUNT(*) FROM sample")[[1]],
  obs                = dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]],
  obs_attribute      = dbGetQuery(con, "SELECT COUNT(*) FROM obs_attribute")[[1]],
  sample_measurement = dbGetQuery(con, "SELECT COUNT(*) FROM sample_measurement")[[1]],
  taxon              = n_pruned$taxon,
  dataset_taxon      = n_pruned$dataset_taxon)
cat(glue(
  "core projection — sample={core$sample %||% 0} obs={core$obs %||% 0} ",
  "obs_attribute={core$obs_attribute %||% 0} ",
  "sample_measurement={core$sample_measurement %||% 0} ",
  "taxon={core$taxon %||% 0} dataset_taxon={core$dataset_taxon %||% 0}\n"))
core projection — sample=212258 obs=487616 obs_attribute=367094 sample_measurement=324284 taxon=1686 dataset_taxon=1167
Code
cat(glue("taxa shard: {n_taxon} taxon rows built, {core$taxon} kept after pruning ",
         "to this dataset's vocabulary + its lineage ancestors"), "\n")
taxa shard: 3415 taxon rows built, 1686 kept after pruning to this dataset's vocabulary + its lineage ancestors 
Code
# the three event levels must each survive at their own grain, and the
# abundance headline must not absorb the size/stage rows
n_site <- dbGetQuery(con, "SELECT COUNT(*) FROM sample WHERE sample_type='site'")[[1]]
n_tow  <- dbGetQuery(con, "SELECT COUNT(*) FROM sample WHERE sample_type='tow'")[[1]]
n_net  <- dbGetQuery(con, "SELECT COUNT(*) FROM sample WHERE sample_type='net'")[[1]]
d_attr <- dbGetQuery(con, "
  SELECT measurement_type, COUNT(*) n FROM obs_attribute GROUP BY 1 ORDER BY 1")
stopifnot(
  "sample must hold one row per site"  = n_site == dbGetQuery(con, "SELECT COUNT(DISTINCT site_uuid) FROM site")[[1]],
  "sample must hold one row per tow"   = n_tow  == dbGetQuery(con, "SELECT COUNT(DISTINCT tow_uuid) FROM tow")[[1]],
  "sample must hold one row per net"   = n_net  == dbGetQuery(con, "SELECT COUNT(DISTINCT net_uuid) FROM net")[[1]],
  "sample_key must be globally unique" =
    dbGetQuery(con, "SELECT COUNT(*) FROM (SELECT sample_key FROM sample
                     GROUP BY 1 HAVING COUNT(*) > 1)")[[1]] == 0,
  "obs must carry only the abundance headline (one row per net x AphiaID x life stage)" =
    core$obs ==
    dbGetQuery(con, "SELECT COUNT(*) FROM (
                       SELECT DISTINCT i.net_uuid, dt.taxon_key, i.life_stage
                       FROM ichthyo i JOIN net n USING (net_uuid)
                       JOIN tow t USING (tow_uuid) JOIN site s USING (site_uuid)
                       LEFT JOIN dataset_taxon dt ON dt.dataset_key = 'swfsc_ichthyo'
                         AND dt.ds_taxa_code = CAST(i.species_id AS VARCHAR)
                       WHERE i.measurement_type IS NULL)")[[1]],
  "obs must carry no size/stage rows" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs
                     WHERE measurement_type <> 'abundance'")[[1]] == 0,
  "obs_attribute must hold exactly body_length + stage" =
    setequal(d_attr$measurement_type, c("body_length", "stage")),
  "the net -> tow -> site chain must resolve" =
    dbGetQuery(con, "SELECT COUNT(*) FROM sample c
                     LEFT JOIN sample p ON c.parent_sample_key = p.sample_key
                     WHERE c.parent_sample_key IS NOT NULL AND p.sample_key IS NULL")[[1]] == 0,
  "every sample_measurement.sample_key must resolve in sample" =
    dbGetQuery(con, "SELECT COUNT(*) FROM sample_measurement m
                     LEFT JOIN sample s USING (sample_key)
                     WHERE s.sample_key IS NULL")[[1]] == 0,
  "every obs.taxon_key must RESOLVE in taxon (not merely be non-NULL)" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs o LEFT JOIN taxon t USING (taxon_key)
                     WHERE o.taxon_key IS NOT NULL AND t.taxon_key IS NULL")[[1]] == 0,
  "every taxon.parent_taxon_key must resolve (the lineage chain)" =
    dbGetQuery(con, "SELECT COUNT(*) FROM taxon c LEFT JOIN taxon p
                     ON c.parent_taxon_key = p.taxon_key
                     WHERE c.parent_taxon_key IS NOT NULL AND p.taxon_key IS NULL")[[1]] == 0,
  # source_uuid (calcofi4db >= 3.32.0, WS-B): every site/tow/net row must carry
  # the provider's own UUID verbatim — a gap here would mean the append_sample()
  # arm's 17th column silently stopped projecting it
  "every site/tow/net sample row must carry source_uuid" =
    dbGetQuery(con, "SELECT COUNT(*) FROM sample
                     WHERE sample_type IN ('site','tow','net') AND source_uuid IS NULL")[[1]] == 0)
cat(glue("sample: {format(n_site, big.mark=',')} sites / {format(n_tow, big.mark=',')} tows / ",
         "{format(n_net, big.mark=',')} nets; obs_attribute ",
         "{paste(sprintf('%s=%s', d_attr$measurement_type, format(d_attr$n, big.mark=',')), collapse=', ')}"), "\n")
sample: 59,976 sites / 74,394 tows / 77,888 nets; obs_attribute body_length=241,328, stage=125,766 
Code
# UUID check (calcofi4db >= 3.32.0, WS-B / design memo D2): site.cruise_uuid is
# the ONLY place a real cruise_uuid<->cruise_key link survives past this chunk —
# the compat block just below replaces `site` with a VIEW carrying only
# cruise_key (not cruise_uuid), and the release's `sample` table has no
# cruise_uuid column at all (a 1.3M-row denormalization to serve one check
# would be worse than the check). So this assertion runs HERE, once, while both
# columns still coexist, and its result travels in manifest.json
# (`mismatches$cruise_uuid`, written below) for check_cruise_key_integrity()
# (calcofi4db::check_cruise_key_integrity(), release_database.qmd) to gate on.
n_cruise_uuid_mismatch <- dbGetQuery(con, "
  SELECT COUNT(*) AS n FROM site s JOIN cruise c ON c.cruise_uuid = s.cruise_uuid
  WHERE s.cruise_key IS DISTINCT FROM c.cruise_key OR s.cruise_key IS NULL")[[1]]
stopifnot(
  "every ichthyo site's cruise_key must agree with the cruise row its cruise_uuid names" =
    n_cruise_uuid_mismatch == 0)
cat(glue("cruise_uuid <-> cruise_key check: {n_cruise_uuid_mismatch} mismatch(es)\n"))
cruise_uuid <-> cruise_key check: 0 mismatch(es)
Code
# serve the retired per-dataset names as VIEWs over the core: the source id comes
# back out of the namespaced sample_key, the containment FK out of
# parent_sample_key, and the net effort columns by pivoting sample_measurement
# back out of long form. Exact for every column the core models, lossy for the
# rest (net.side, tow.tow_number, the legacy site columns) — which the release
# drops anyway. The real tables are DROPped: they are 213k/459k rows and are no
# longer written to parquet.
compat <- list(
  site = compat_event_sql(ds_key, "site", "site_uuid", NULL,
    c(order_occ = "order_occ", longitude = "longitude", latitude = "latitude",
      cruise_key = "cruise_key", geom = "geom", grid_key = "grid_key",
      site_key = "site_key")),
  tow = compat_event_sql(ds_key, "tow", "tow_uuid", "site_uuid",
    c(tow_type_key = "tow_type", datetime_start_utc = "datetime")),
  net = compat_event_sql(ds_key, "net", "net_uuid", "tow_uuid", character(),
    c(standard_haul_factor = "std_haul_factor", volume_sampled = "volume_sampled",
      prop_sorted = "prop_sorted", smallplankton = "small_plankton_biomass",
      totalplankton = "total_plankton_biomass")))
for (nm in names(compat)) {
  # DROP has to know which it is: DuckDB refuses "DROP TABLE" on a view (and
  # vice versa), and a re-run inside one session finds the view already there
  t <- dbGetQuery(con, glue(
    "SELECT table_type FROM information_schema.tables WHERE table_name = '{nm}'"))
  if (nrow(t)) {
    kind <- if (grepl("VIEW", t$table_type[1], ignore.case = TRUE)) "VIEW" else "TABLE"
    invisible(dbExecute(con, glue('DROP {kind} IF EXISTS "{nm}"')))
  }
  invisible(dbExecute(con, glue("CREATE OR REPLACE VIEW {nm} AS {compat[[nm]]}")))
}
n_compat <- sapply(names(compat), function(nm)
  dbGetQuery(con, glue("SELECT COUNT(*) FROM {nm}"))[[1]])
stopifnot(
  "compat views must reconstruct the source grains exactly" =
    all(n_compat[c("site", "tow", "net")] == c(n_site, n_tow, n_net)))
cat(glue("compat views over core: ",
         "{paste(sprintf('%s=%s', names(n_compat), format(n_compat, big.mark=',')), collapse=', ')}"), "\n")
compat views over core: site=59,976, tow=74,394, net=77,888 

23 Write Parquet Outputs

Export tables to parquet files for downstream use.

Code
# collect mismatches for manifest
mismatches <- list(
  ships = collect_ship_mismatches(con, "cruise"),
  cruise_keys = collect_cruise_key_mismatches(con, "cruise"),
  # calcofi4db >= 3.32.0 (WS-B / design memo D2): site.cruise_uuid <-> cruise.cruise_key
  # agreement, computed in the emit_core chunk while site still carried
  # cruise_uuid (the compat VIEW below drops it). release_database.qmd's
  # check_cruise_key_integrity() reads this and fails the release if it is
  # missing or non-zero.
  cruise_uuid = tibble(n_mismatch = n_cruise_uuid_mismatch)
)

# write parquet files with manifest
# core shards + the shared reference tables this ingest owns for the whole
# database (grid/cruise/ship/lookup), and the dataset's own `fish` table. The
# per-dataset site/tow/net/ichthyo tables are no longer written — they are VIEWs
# over the core now.
tbls_out <- core_output_tables(
  con, extra = c("grid", "cruise", "ship", "lookup", "dataset", "fish"))
parquet_stats <- write_parquet_outputs(
  con              = con,
  output_dir       = dir_parquet,
  tables           = tbls_out,
  sort_by          = list(
    obs    = c("grid_key", "measurement_type"),
    sample = "hilbert:longitude,latitude"),
  strip_provenance = FALSE,
  mismatches       = mismatches
)

parquet_stats |>
  mutate(file = basename(path)) |>
  select(-path) |>
  datatable(caption = "Parquet export statistics")

24 Write Metadata

Build metadata.json sidecar file documenting all tables and columns in parquet outputs. DuckDB COMMENT ON does not propagate to parquet, so this provides the metadata externally.

Code
metadata_path <- build_metadata_json(
  con = con,
  d_tbls_rd = d$d_tbls_rd,
  d_flds_rd = d$d_flds_rd,
  metadata_derived_csv = c(
    here("metadata/core_dictionary.csv"),
    here("metadata/swfsc/ichthyo/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 (12 tables, 140 columns) — these render blank in cc_describe_table() / cc_db_catalog():
tables with no description_md: 1    dataset
columns with no description_md: 42    cruise.date_ym, cruise.ship_key, cruise._source_file, cruise._source_row, cruise._ingested_at, cruise._source_uuid, cruise.date_min, cruise.date_max, dataset.provider, dataset.dataset, dataset.dataset_name, dataset.dataset_name_short (+30 more)
measurement columns with no units: 39    cruise.date_ym, cruise.date_min, cruise.date_max, 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 (+27 more)
  backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run
Code
# write relationships.json sidecar with PKs/FKs
# `fish` (this dataset's own table) is declared in core_relationships() (calcofi4db
# dev): key (sample_key, species_id), FKs to the net in `sample` and to `taxon`
stopifnot("calcofi4db must declare fish's key (core_relationships)" =
            identical(core_relationships("fish")$primary_keys$fish, c("sample_key", "species_id")))
build_relationships_json(
  rels = core_relationships(tbls_out),
  output_dir = dir_parquet,
  provider = provider,
  dataset = dataset
)
[1] "/Users/bbest/Github/CalCOFI/workflows/data/parquet/swfsc_ichthyo/relationships.json"
Code
# show metadata summary
metadata <- jsonlite::fromJSON(metadata_path)
tibble(
  table = names(metadata$tables),
  n_cols = map_int(
    names(metadata$tables),
    ~ sum(grepl(glue("^{.x}\\."), names(metadata$columns)))
  ),
  name_long = map_chr(metadata$tables, ~ .x$name_long)
) |>
  datatable(caption = "Table metadata summary")
Code
listviewer::jsonedit(
  jsonlite::fromJSON(metadata_path, simplifyVector = FALSE),
  mode = "view")
Code
listviewer::jsonedit(
  jsonlite::fromJSON(
    file.path(dir_parquet, "relationships.json"),
    simplifyVector = FALSE),
  mode = "view")

25 Upload to GCS Archive

Upload parquet files, manifest, and metadata sidecar to gs://calcofi-db/ingest/{provider}_{dataset}/.

Code
gcs_ingest_prefix <- glue("ingest/{dir_label}")
gcs_bucket <- "calcofi-db"

# sync to GCS — only uploads new or changed files
sync_results <- sync_to_gcs(
  local_dir = dir_stage,
  sidecar_dir = dir_parquet,
  gcs_prefix = gcs_ingest_prefix,
  bucket = gcs_bucket
)

26 Cleanup

Code
# close local wrangling database connection
close_duckdb(con)
message("Local wrangling database connection closed")

# note: parquet outputs are in data/parquet/swfsc_ichthyo/
# these can be used to update the Working DuckLake in a separate workflow
message(glue("Parquet outputs written to: {dir_parquet}"))
message(glue("GCS outputs at: gs://{gcs_bucket}/{gcs_ingest_prefix}/"))

27 TODO

Code
devtools::session_info()
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.1 (2026-06-24)
 os       macOS Sequoia 15.7.1
 system   aarch64, darwin23
 ui       X11
 language (EN)
 collate  en_US.UTF-8
 ctype    en_US.UTF-8
 tz       Europe/Rome
 date     2026-10-04
 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.6.0)
   arrow                 25.0.1     2026-08-23 [1] CRAN (R 4.6.1)
   askpass               1.2.1      2024-10-04 [1] CRAN (R 4.6.0)
   assertthat            0.2.1      2019-03-21 [1] CRAN (R 4.6.0)
   backports             1.5.1      2026-04-03 [1] CRAN (R 4.6.0)
   base64enc             0.1-6      2026-02-02 [1] CRAN (R 4.6.0)
   bit                   4.6.0      2025-03-06 [1] CRAN (R 4.6.0)
   bit64                 4.8.6      2026-09-01 [1] CRAN (R 4.6.1)
   blob                  1.3.0      2026-01-14 [1] CRAN (R 4.6.0)
   brew                  1.0-10     2023-12-16 [1] CRAN (R 4.6.0)
   brio                  1.1.5      2024-04-24 [1] CRAN (R 4.6.0)
   broom                 1.0.13     2026-05-14 [1] CRAN (R 4.6.0)
   bslib                 0.12.0     2026-08-04 [1] CRAN (R 4.6.1)
   cachem                1.1.0      2024-05-16 [1] CRAN (R 4.6.0)
 P calcofi4db          * 4.20.0     2026-10-04 [?] load_all()
 P calcofi4r           * 1.25.0     2026-10-04 [?] load_all()
   chromote              0.5.1      2025-04-24 [1] CRAN (R 4.6.0)
   class                 7.3-24     2026-08-03 [1] CRAN (R 4.6.1)
   classInt              0.4-11     2025-01-08 [1] CRAN (R 4.6.0)
   cli                   3.6.6      2026-04-09 [1] CRAN (R 4.6.0)
   codetools             0.2-20     2024-03-31 [1] CRAN (R 4.6.1)
   commonmark            2.0.0      2025-07-07 [1] CRAN (R 4.6.0)
   crayon                1.5.3      2024-06-20 [1] CRAN (R 4.6.0)
   crosstalk             1.2.2      2025-08-26 [1] CRAN (R 4.6.0)
   crul                  1.6.0      2025-07-23 [1] CRAN (R 4.6.0)
   curl                  8.0.0      2026-08-25 [1] CRAN (R 4.6.1)
   data.table            1.18.6.1   2026-08-24 [1] CRAN (R 4.6.1)
   DBI                 * 1.3.0      2026-02-25 [1] CRAN (R 4.6.0)
   dbplyr                2.6.0      2026-06-17 [1] CRAN (R 4.6.0)
   desc                  1.4.3      2023-12-10 [1] CRAN (R 4.6.0)
   devtools              2.5.2      2026-04-30 [1] CRAN (R 4.6.0)
   DiagrammeR            1.0.12     2026-04-27 [1] CRAN (R 4.6.0)
   DiagrammeRsvg         0.1        2016-02-04 [1] CRAN (R 4.6.0)
   digest                0.6.39     2025-11-19 [1] CRAN (R 4.6.0)
   dm                    1.1.2      2026-05-17 [1] CRAN (R 4.6.0)
   dplyr               * 1.2.1      2026-04-03 [1] CRAN (R 4.6.0)
   DT                  * 0.34.0     2025-09-02 [1] CRAN (R 4.6.0)
   duckdb                1.5.5      2026-07-25 [1] CRAN (R 4.6.1)
   dygraphs              1.1.1.6    2018-07-11 [1] CRAN (R 4.6.0)
   e1071                 1.7-17     2025-12-18 [1] CRAN (R 4.6.0)
   ellipsis              0.3.3      2026-04-04 [1] CRAN (R 4.6.0)
   evaluate              1.0.5      2025-08-27 [1] CRAN (R 4.6.0)
   farver                2.1.2      2024-05-13 [1] CRAN (R 4.6.0)
   fastmap               1.2.0      2024-05-15 [1] CRAN (R 4.6.0)
   fs                  * 2.1.0      2026-04-18 [1] CRAN (R 4.6.0)
   fuzzyjoin             0.1.8      2026-02-20 [1] CRAN (R 4.6.0)
   gargle              * 1.6.1      2026-01-29 [1] CRAN (R 4.6.0)
   generics              0.1.4      2025-05-09 [1] CRAN (R 4.6.0)
   geojsonsf             2.0.5      2025-11-26 [1] CRAN (R 4.6.0)
   ggplot2               4.0.3      2026-04-22 [1] CRAN (R 4.6.0)
   glue                * 1.8.1      2026-04-17 [1] CRAN (R 4.6.0)
   googleAuthR           2.0.2.1    2026-01-09 [1] CRAN (R 4.6.0)
   googleCloudStorageR   0.7.0      2021-12-16 [1] CRAN (R 4.6.0)
   googledrive         * 2.1.2      2025-09-10 [1] CRAN (R 4.6.0)
   gsw                   1.2-0      2024-08-19 [1] CRAN (R 4.6.0)
   gtable                0.3.6      2024-10-25 [1] CRAN (R 4.6.0)
   here                * 1.0.2      2025-09-15 [1] CRAN (R 4.6.0)
   highcharter           0.9.5      2026-04-22 [1] CRAN (R 4.6.0)
   hms                   1.1.4      2025-10-17 [1] CRAN (R 4.6.0)
   htmltools           * 0.5.9      2025-12-04 [1] CRAN (R 4.6.0)
   htmlwidgets           1.6.4      2023-12-06 [1] CRAN (R 4.6.0)
   httpcode              0.3.0      2020-04-10 [1] CRAN (R 4.6.0)
   httpuv                1.6.17     2026-03-18 [1] CRAN (R 4.6.0)
   httr                  1.4.9      2026-09-01 [1] CRAN (R 4.6.1)
   httr2                 1.3.0      2026-07-13 [1] CRAN (R 4.6.1)
   igraph                2.3.3      2026-06-26 [1] CRAN (R 4.6.1)
   isoband               0.3.0      2025-12-07 [1] CRAN (R 4.6.0)
   janitor             * 2.2.1      2024-12-22 [1] CRAN (R 4.6.0)
   jquerylib             0.1.4      2021-04-26 [1] CRAN (R 4.6.0)
   jsonlite            * 2.0.0      2025-03-27 [1] CRAN (R 4.6.0)
   KernSmooth            2.23-27    2026-08-12 [1] CRAN (R 4.6.1)
   knitr               * 1.52       2026-09-06 [1] CRAN (R 4.6.1)
   later                 1.4.8      2026-03-05 [1] CRAN (R 4.6.0)
   lattice               0.23-1     2026-08-12 [1] CRAN (R 4.6.1)
   leafem                0.2.5      2025-08-28 [1] CRAN (R 4.6.0)
   leaflet               2.2.3      2025-09-04 [1] CRAN (R 4.6.0)
   leaflet.providers     3.0.0      2026-03-18 [1] CRAN (R 4.6.0)
   leafpop               0.1.0      2021-05-22 [1] CRAN (R 4.6.0)
   librarian             1.8.1      2021-07-12 [1] CRAN (R 4.6.0)
   lifecycle             1.0.5      2026-01-08 [1] CRAN (R 4.6.0)
   listviewer          * 4.0.0      2023-09-30 [1] CRAN (R 4.6.0)
   litedown            * 0.11       2026-08-26 [1] CRAN (R 4.6.1)
   lubridate           * 1.9.5      2026-02-04 [1] CRAN (R 4.6.0)
   magrittr              2.0.5      2026-04-04 [1] CRAN (R 4.6.0)
   mapgl                 0.5.0.9000 2026-09-01 [1] Github (bbest/mapgl@484e869)
   mapview             * 2.11.4     2025-09-08 [1] CRAN (R 4.6.0)
   markdown              2.0        2025-03-23 [1] CRAN (R 4.6.0)
   Matrix                1.7-6      2026-07-25 [1] CRAN (R 4.6.1)
   memoise               2.0.1      2021-11-26 [1] CRAN (R 4.6.0)
   mgcv                  1.9-4      2025-11-07 [1] CRAN (R 4.6.1)
   mime                  0.13       2025-03-17 [1] CRAN (R 4.6.0)
   nlme                  3.1-171    2026-09-01 [1] CRAN (R 4.6.1)
   openssl               2.4.2      2026-06-09 [1] CRAN (R 4.6.0)
   otel                  0.2.0      2025-08-29 [1] CRAN (R 4.6.0)
   pillar                1.11.1     2025-09-17 [1] CRAN (R 4.6.0)
   pkgbuild              1.4.8      2025-05-26 [1] CRAN (R 4.6.0)
   pkgconfig             2.0.3      2019-09-22 [1] CRAN (R 4.6.0)
   pkgload               1.5.3      2026-06-15 [1] CRAN (R 4.6.0)
   plotly                4.12.1     2026-07-22 [1] CRAN (R 4.6.1)
   png                   0.1-9      2026-03-15 [1] CRAN (R 4.6.0)
   processx              3.9.0      2026-04-22 [1] CRAN (R 4.6.0)
   promises              1.5.0      2025-11-01 [1] CRAN (R 4.6.0)
   proxy                 0.4-29     2025-12-29 [1] CRAN (R 4.6.0)
   purrr               * 1.2.2      2026-04-10 [1] CRAN (R 4.6.0)
   quantmod              0.4.29     2026-06-28 [1] CRAN (R 4.6.1)
   R6                    2.6.1      2025-02-15 [1] CRAN (R 4.6.0)
   rappdirs              0.3.4      2026-01-17 [1] CRAN (R 4.6.0)
   raster                3.6-32     2025-03-28 [1] CRAN (R 4.6.0)
   RColorBrewer          1.1-3      2022-04-03 [1] CRAN (R 4.6.0)
   Rcpp                  1.1.2      2026-07-05 [1] CRAN (R 4.6.1)
   readr               * 2.2.0      2026-02-19 [1] CRAN (R 4.6.0)
   rlang               * 1.3.0      2026-07-05 [1] CRAN (R 4.6.1)
   rlist                 0.4.6.2    2021-09-03 [1] CRAN (R 4.6.0)
   rmarkdown             2.32       2026-09-01 [1] CRAN (R 4.6.1)
   rnaturalearth         1.2.0      2026-01-19 [1] CRAN (R 4.6.0)
   rnaturalearthhires    1.0.0.9000 2026-08-29 [1] Github (ropensci/rnaturalearthhires@e4736f6)
   RPostgres             1.4.10     2026-02-16 [1] CRAN (R 4.6.0)
   rprojroot             2.1.1      2025-08-26 [1] CRAN (R 4.6.0)
   rstudioapi            0.19.0     2026-06-11 [1] CRAN (R 4.6.0)
   s2                    1.1.12     2026-09-03 [1] CRAN (R 4.6.1)
   S7                    0.2.2      2026-04-22 [1] CRAN (R 4.6.0)
   sass                  0.4.10     2025-04-11 [1] CRAN (R 4.6.0)
   satellite             1.0.6      2025-08-21 [1] CRAN (R 4.6.0)
   scales                1.4.0      2025-04-24 [1] CRAN (R 4.6.0)
   sessioninfo           1.2.4      2026-06-04 [1] CRAN (R 4.6.0)
   sf                  * 1.1-2      2026-07-23 [1] CRAN (R 4.6.1)
   shiny                 1.14.0     2026-06-21 [1] CRAN (R 4.6.0)
   shinyWidgets          0.9.1      2026-03-09 [1] CRAN (R 4.6.0)
   snakecase             0.11.1     2023-08-27 [1] CRAN (R 4.6.0)
   sp                    2.2-3      2026-07-19 [1] CRAN (R 4.6.1)
   stars                 0.7-3      2026-07-20 [1] CRAN (R 4.6.1)
   stringi               1.8.9      2026-08-04 [1] CRAN (R 4.6.1)
   stringr             * 1.6.0      2025-11-04 [1] CRAN (R 4.6.0)
   svglite               2.2.2      2025-10-21 [1] CRAN (R 4.6.0)
   systemfonts           1.3.2      2026-03-05 [1] CRAN (R 4.6.0)
   terra                 1.9-46     2026-08-22 [1] CRAN (R 4.6.1)
   testthat            * 3.3.2      2026-01-11 [1] CRAN (R 4.6.0)
   textshaping           1.0.5      2026-03-06 [1] CRAN (R 4.6.0)
   tibble              * 3.3.1      2026-01-11 [1] CRAN (R 4.6.0)
   tidyr               * 1.3.2      2025-12-19 [1] CRAN (R 4.6.0)
   tidyselect            1.2.1      2024-03-11 [1] CRAN (R 4.6.0)
   timechange            0.4.0      2026-01-29 [1] CRAN (R 4.6.0)
   triebeard             0.4.1      2023-03-04 [1] CRAN (R 4.6.0)
   TTR                   0.24.4     2023-11-28 [1] CRAN (R 4.6.0)
   tzdb                  0.5.0      2025-03-15 [1] CRAN (R 4.6.0)
   units               * 1.0-1      2026-03-11 [1] CRAN (R 4.6.0)
   urltools              1.7.3.1    2025-06-12 [1] CRAN (R 4.6.0)
   usethis               3.2.1      2025-09-06 [1] CRAN (R 4.6.0)
   uuid                * 1.2-2      2026-01-23 [1] CRAN (R 4.6.0)
   V8                    8.2.0      2026-04-21 [1] CRAN (R 4.6.0)
   vctrs                 0.7.3      2026-04-11 [1] CRAN (R 4.6.0)
   viridisLite           0.4.3      2026-02-04 [1] CRAN (R 4.6.0)
   visNetwork            2.1.4      2025-09-04 [1] CRAN (R 4.6.0)
   vroom                 1.7.1      2026-03-31 [1] CRAN (R 4.6.0)
   webshot2            * 0.1.2      2025-04-23 [1] CRAN (R 4.6.0)
   websocket             1.4.4      2025-04-10 [1] CRAN (R 4.6.0)
   withr                 3.0.3      2026-06-19 [1] CRAN (R 4.6.0)
   wk                    0.9.5      2025-12-18 [1] CRAN (R 4.6.0)
   worrms                0.4.3      2023-06-20 [1] CRAN (R 4.6.0)
   xfun                  0.60       2026-07-09 [1] CRAN (R 4.6.1)
   xtable                1.8-8      2026-02-22 [1] CRAN (R 4.6.0)
   xts                   0.14.2     2026-02-28 [1] CRAN (R 4.6.0)
   yaml                  2.3.12     2025-12-10 [1] CRAN (R 4.6.0)
   zip                   3.0.2      2026-08-04 [1] CRAN (R 4.6.1)
   zoo                   1.9-0      2026-07-31 [1] CRAN (R 4.6.1)

 [1] /Library/Frameworks/R.framework/Versions/4.6/Resources/library

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

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