Ingest NOAA CalCOFI Database

Published

2026-08-14

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

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

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

10 Data Quality Improvements

This section applies data corrections and validates referential integrity.

10.1 Data Corrections

Apply known data corrections identified by data managers.

Code
# apply data corrections
apply_data_corrections(con, verbose = TRUE)

10.2 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
  )
}
[1] "/Users/bbest/Github/CalCOFI/workflows/data/flagged/invalid_egg_stages.csv"
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): 790 rows flagged → calcofi/workflows: data/flagged/invalid_egg_stages.csv

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

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

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

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

# 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 = ', ')}"
))

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

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

12 Schema Documentation

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

12.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)

12.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)

13 Add Spatial

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

13.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] 218
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] 218
Code
dbExecute(con, "ALTER TABLE grid DROP COLUMN geom_ctr_wkb")
[1] 0
Code
message("Grid table created with geometry columns")

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

13.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] 60413
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"
)

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

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

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

17 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'

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

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

19.1 cruise (691 rows)

19.2 ship (48 rows)

19.3 site (61,104 rows)

19.4 tow (75,506 rows)

19.5 net (76,512 rows)

19.6 species (1,167 rows)

19.7 taxon (3,412 rows)

19.8 taxa_rank (54 rows)

19.9 ichthyo (852,228 rows)

19.10 lookup (26 rows)

19.11 grid (218 rows)

19.12 segment (60,413 rows)

20 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: 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 <- build_dataset_taxon(con,   mt_taxon, tx_over)
n_pruned   <- prune_taxon_shard(con, ds_key)

# 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.
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
  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"))
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, NULL::DOUBLE AS depth_max_m,
         t.tow_type_key AS tow_type
  FROM tow t JOIN site s USING (site_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, NULL::DOUBLE AS depth_max_m,
         t.tow_type_key AS tow_type
  FROM net n JOIN tow t USING (tow_uuid) JOIN site s USING (site_uuid)"))

# 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.
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(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"))

# 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`.
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,
         i.tally, 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')"))

# 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=213122 obs=482250 obs_attribute=369978 sample_measurement=320110 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" =
    core$obs ==
    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]],
  "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)
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: 61,104 sites / 75,506 tows / 76,512 nets; obs_attribute body_length=241,871, stage=128,107 
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=61,104, tow=75,506, net=76,512 

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

# write parquet files with manifest
# core shards + the shared reference tables this ingest owns for the whole
# database (grid/cruise/ship/lookup). 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"))
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")

22 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 (11 tables, 127 columns) — these render blank in cc_describe_table() / cc_db_catalog():
tables with no description_md: 1    dataset
columns with no description_md: 34    cruise.date_ym, cruise.ship_key, cruise._source_file, cruise._source_row, cruise._ingested_at, cruise._source_uuid, dataset.provider, dataset.dataset, dataset.dataset_name, dataset.dataset_name_short, dataset.category, dataset.color (+22 more)
measurement columns with no units: 32    cruise.date_ym, dataset.dataset_name_short, dataset.category, dataset.color, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others, dataset.tables, dataset.coverage_temporal, dataset.coverage_spatial (+20 more)
  backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run
Code
# write relationships.json sidecar with PKs/FKs
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")

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

24 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}/"))

25 TODO

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

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

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

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

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