Ingest CDFW Dungeness Crab Megalopae

Published

2026-08-14

1 Overview

Source: three hand-maintained Excel workbooks plus correspondence, under data-public/cdfw/dungeness-crab/. Provider cdfw (California Department of Fish and Wildlife), which commissioned the work; the sorting itself was done at the SIO Pelagic Invertebrate Collection.

Workbook Becomes Rows
Dungeness Time Series - Angela Klemmedson.xlsx (Compiled Cruises) dungeness_sample + dungeness_measurement + dungeness_carapace 310 samples
Cancer Magister sorting update as of 02-15-12_EJones.xlsx dungeness_sorting_log 2,011 samples
ScrippsArchivedMegalopae_sent_5_10_2012.xlsx dungeness_specimen 5 specimens

The two PDFs and the .docx are the provenance for what the numbers mean and are archived alongside the data, not parsed.

Why this is interesting: 24 M. magister megalopae in 310 samples over seven years — a near-absence record. The 2012 correspondence establishes that historic CalCOFI samples labelled Cancer magister are probably C. productus, so the dungeness_specimen verification table is load-bearing rather than trivia.

In the release as of 2026-08-14. It was held out from first ingest until CDFW confirmed permission, a licence and a citation (Q01, answered 2026-08-13 — CC BY 4.0). Nothing about the outputs changed when it entered: this notebook always wrote its complete parquet, including its slice of the consolidated core, and release_database.qmd simply stopped skipping it.

Code
graph LR
  X1[Compiled Cruises] --> S[dungeness_sample<br/>station + effort]
  X1 --> M[dungeness_measurement<br/>counts, long]
  X1 --> C[dungeness_carapace<br/>per individual]
  X2[sorting log] --> L[dungeness_sorting_log<br/>sorted / unsorted]
  X3[archived megalopae] --> V[dungeness_specimen<br/>ID verification]
  M -.dungeness_sample_id.-> S
  C -.dungeness_sample_id.-> S
  M -.measurement_type.-> T[measurement_type]
  S -.site_sample_key.-> I[(swfsc_ichthyo<br/>sample / ship / grid)]

graph LR
  X1[Compiled Cruises] --> S[dungeness_sample<br/>station + effort]
  X1 --> M[dungeness_measurement<br/>counts, long]
  X1 --> C[dungeness_carapace<br/>per individual]
  X2[sorting log] --> L[dungeness_sorting_log<br/>sorted / unsorted]
  X3[archived megalopae] --> V[dungeness_specimen<br/>ID verification]
  M -.dungeness_sample_id.-> S
  C -.dungeness_sample_id.-> S
  M -.measurement_type.-> T[measurement_type]
  S -.site_sample_key.-> I[(swfsc_ichthyo<br/>sample / ship / grid)]

2 Setup

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

cc           <- read_calcofi_meta(here("ingest_cdfw_dungeness-crab.qmd"))
provider     <- cc$provider
dataset      <- cc$dataset
tables_owned <- cc$tables_owned
dir_label    <- glue("{provider}_{dataset}")
dir_parquet  <- here(glue("data/parquet/{dir_label}"))
dir_stage  <- cc_stage_path("parquet", dir_label, create = TRUE)
db_path      <- here(glue("data/wrangling/{dir_label}.duckdb"))
dir_src      <- path_expand(glue("{dir_data}/cdfw/dungeness-crab"))

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

# the tables this ingest owns
dcr_tbls <- c("dungeness_sample", "dungeness_measurement", "dungeness_carapace",
              "dungeness_sorting_log", "dungeness_specimen")

# Both buckets are world-readable and uploading is a one-way door, so this was
# opt-in while permission to publish was itself the open blocker (Q01). CDFW
# answered on 2026-08-13 — CC BY 4.0, see the citation in the front-matter — so
# this ingest now archives and syncs like every other one. The flag stays as a
# named switch rather than being deleted: it is the thing to flip if a future
# dataset arrives under the same unsettled-permission conditions.
publish_to_gcs <- TRUE

3 Read Source Workbooks

The workbook parsing lives in libs/parse_dungeness_crab.R so it is reviewable and re-runnable. Three quirks it absorbs, each of which silently corrupts the data if missed:

  • the time series workbook is saved with Excel’s 1904 date epoch (date1904="1"), so every date read as text is four years early — the cruise labels are what caught it (1404SH reading as 2010);
  • the sorting log lost the leading month from most SampleDate cells (/5/1988), recoverable from its separate Month column;
  • the sorting log pads numbers with U+00A0, which defeats as.numeric().
Code
stopifnot("source dir not found" = dir_exists(dir_src))
# archive the sources (including the PDF/DOCX provenance) — see publish_to_gcs
if (publish_to_gcs) {
  sync_to_gcs(local_dir = dir_src, gcs_prefix = glue("archive/{provider}/{dataset}"),
              bucket = "calcofi-files-public", exclude = c(".DS_Store", "*.tmp"))
} else {
  cat("publish_to_gcs is FALSE — sources NOT archived to gs://calcofi-files-public",
      "(pending Q01: licence + permission to publish)\n")
}
# A tibble: 6 × 4
  file    action    size reason        
  <chr>   <chr>    <dbl> <chr>         
1 <rsync> uploaded    NA parallel rsync
2 <rsync> uploaded    NA parallel rsync
3 <rsync> uploaded    NA parallel rsync
4 <rsync> uploaded    NA parallel rsync
5 <rsync> uploaded    NA parallel rsync
6 <rsync> uploaded    NA parallel rsync
Code
f_ts   <- file.path(dir_src, "Dungeness Time Series - Angela Klemmedson.xlsx")
f_log  <- file.path(dir_src, "Cancer Magister sorting update as of 02-15-12_EJones.xlsx")
f_spec <- file.path(dir_src, "ScrippsArchivedMegalopae_sent_5_10_2012.xlsx")
stopifnot(file_exists(f_ts), file_exists(f_log), file_exists(f_spec))

d_ts   <- read_dungeness_timeseries(f_ts)
d_log  <- read_dungeness_sorting_log(f_log)
d_spec <- read_dungeness_specimens(f_spec)
d_note <- read_dungeness_notes(f_ts)

cat(glue("time series {nrow(d_ts)} samples ({format(min(d_ts$date))} to ",
         "{format(max(d_ts$date))}); sorting log {nrow(d_log)} samples; ",
         "specimens {nrow(d_spec)}"), "\n")
time series 310 samples (2008-04-06 to 2014-05-03); sorting log 2011 samples; specimens 5 
Code
# the workbook's own "Plots" sheet summarises 310 samples / 24 M. magister; hold
# the parse to that, so a re-export that changes the sheet fails loudly here
stopifnot(
  "expected 310 samples in 'Compiled Cruises'" = nrow(d_ts) == 310,
  "expected 24 M. magister megalopae in total" =
    sum(d_ts$n_mega_magister, na.rm = TRUE) == 24,
  "1904 epoch not applied — dates predate the 2008-2014 series" =
    min(d_ts$date) >= as.Date("2008-01-01"))

4 Methods, in the Sorter’s Own Words

The workbook’s Notes sheet is empty to every spreadsheet reader — its content is a floating text box, which lives in xl/drawings/ rather than in any cell, so read_excel() returns 0×0. It is the most valuable single artifact here: Angela Klemmedson’s dated statement of gear, magnification, measurement landmarks, station bounds, results, and her reasoning about aliquots. Recovering it answered three questions this ingest had open, so it is reproduced verbatim below rather than summarized.

Three things it settles:

  1. Counts are occurrence, not density, by the author’s explicit choice“I am ignoring ‘Aliq.’ and ‘Volume’ because I am not measuring the density of megalopae in a volume of water, simply the occurrence of megalopae in samples… therefore I will not adjust the numbers of megalopae.” So the raw counts this ingest stores are what the author intended, and the aliquot column is context, not a correction factor.
  2. “14 samples (15 total removal vials)” — independent confirmation of the 15-rows / 14-occupations discrepancy found below, and its explanation: the repeated station occupation is a second removal vial, not a duplicate row.
  3. The non-Dungeness counts are lower-confidence“Data beyond Dungeness crab megalopae should be used more as a guideline… there is potential for error in some of the identifications.” Carried into the column descriptions so it reaches consumers.
NoteNotes sheet, “Dungeness Time Series - Angela Klemmedson.xlsx” (recovered from xl/drawings/drawing1.xml)

Time Series of Dungeness Crab (Metacarcinus magister) 2008-2014

Methods: CalCOFI CalBOBL samples (.505 mesh, oblique tow, formaldehyde fixitive/preservative) from Spring cruises, lines < 80.0 stations < 90.0. Samples were transferred from formaldehyde to filtered seawater using .303 mesh, sorted under microscope at 60x and identified/measured at 120/240x using microscope’s micrometer. M. magister megalopae were removed, measured, and moved into vials being stored in Scripp’s Pelagic Invertebrates Collection (PIC). Megalopae measured from tip of rostrum to posterior margin of carapace. Zoea measured from base of dorsal spine to tip of telson (with body straightened). Observations of other crab larvae (categorized into: ‘other megalopae’ ‘cancer zoea’ and ‘other zoea’) were also recorded to understand intraspecific and interspecific relationships. Abnormal occurances and abundances of organisms were recorded for future reference and for PIC data.

Completed: 13 total cruises/310 total samples sorted; data spans 7 years (2008-2014); M. magister megalopae found in 14 samples (15 total removal vials); 24 total M. magister megalopae (5.8 - 7.3mm)

Notes: Data beyond Dungeness crab megalopae should be used more as a guideline - extends beyond the focus of the study and there is potential for error in some of the identifications. 1304SH: larvae are indicated as Brachyura because first cruise sorting for crab larvae and not yet confident with detailed identification. Likely in genus Cancer, but could also be Majidae, Grapsidae, etc. *Calculating abundances: For now I am ignoring “Aliq.” and “Volume” because I am not measuring the density of megalopae in a volume of water, simply the occurence of megalopae in samples. Since, theoretically, when samples are split, the two jars are equal, a 50% aliq. with 1 megalopae should have 2 megalopae when compared with 100% aliqots. But with the scarcity of megalopae in the samples I am worried that could significantly change the data. If I follow that reasoning, it could also be assumed that many of the other 50% aliquots I sorted and didn’t find megalopae could have had megalope in the remainder jar, but it is impossible to know which ones those are… therefore I will not adjust the numbers of megalopae.

10/22/15 Angela Klemmedson - California Dept. Fish & Wildlife

5 Resolve Cruise + Position from the CalCOFI Reference

The time series records a cruise label and a CalCOFI line/station/order-of- occupation, but no coordinates. Both are recoverable from the CalCOFI reference tables:

  • cruise_key: the label is YYMM + the two-letter CalCOFI ship abbreviation, which is ship.ship_key — so 1404SH2014-04-3322 (Bell M. Shimada).
  • position: join the resulting (cruise_key, site_key, order_occ) to the station occupations in the swfsc_ichthyo core sample table. That triple identifies a physical station occupation, so the match also links each sorted sample back to the tows and nets it came from via site_sample_key.
Code
load_prior_tables(
  con, parquet_dir = cc_stage_path("parquet", "swfsc_ichthyo"),
  tables = c("ship", "cruise", "grid", "sample"),
  geom_tables = c("grid", "sample"), as_view = TRUE)
# A tibble: 4 × 3
  table    rows has_geom
  <chr>   <dbl> <lgl>   
1 cruise    691 FALSE   
2 grid      218 TRUE    
3 sample 213122 TRUE    
4 ship       48 FALSE   
Code
d_ship  <- dbGetQuery(con, "SELECT ship_key, ship_nodc, ship_name FROM ship")
valid_ck <- dbGetQuery(con, "SELECT DISTINCT cruise_key FROM cruise")$cruise_key

d_cruise_lbl <- parse_cruise_label(unique(d_ts$cruise_orig)) |>
  left_join(d_ship, by = "ship_key") |>
  mutate(cruise_key = if_else(
    is.na(year) | is.na(ship_nodc), NA_character_,
    sprintf("%04d-%02d-%s", year, month, ship_nodc))) |>
  mutate(cruise_key = if_else(cruise_key %in% valid_ck, cruise_key, NA_character_))

stopifnot("every cruise label must resolve to a known cruise_key" =
            !any(is.na(d_cruise_lbl$cruise_key)))
d_cruise_lbl |>
  select(cruise_orig, ship_key, ship_name, cruise_key) |>
  datatable(caption = "Cruise label → cruise_key", options = list(dom = "t"),
            rownames = FALSE)
Code
# CalCOFI station occupations: one row per (cruise, station, order of occupation)
d_site <- dbGetQuery(con, "
  SELECT cruise_key, site_key, order_occ,
         sample_key AS site_sample_key, latitude, longitude, datetime
  FROM sample WHERE sample_type = 'site'")

d_ts_keyed <- d_ts |>
  left_join(select(d_cruise_lbl, cruise_orig, ship_key, cruise_key),
            by = "cruise_orig") |>
  left_join(d_site, by = c("cruise_key", "site_key", "order_occ")) |>
  mutate(position_source = if_else(
    is.na(site_sample_key), "unmatched", "swfsc_ichthyo:site"))

n_matched <- sum(d_ts_keyed$position_source != "unmatched")
cat(glue("station match: {n_matched}/{nrow(d_ts_keyed)} samples ",
         "({round(100 * n_matched / nrow(d_ts_keyed), 1)}%)"), "\n")
station match: 306/310 samples (98.7%) 
Code
# an independent check on the match AND on the unconfirmed time zone: the source
# date should agree with the matched occupation's own timestamp
d_chk <- d_ts_keyed |>
  filter(position_source != "unmatched") |>
  mutate(d_days = as.integer(date - as.Date(datetime)))
cat(glue("date agreement with matched occupation: ",
         "{sum(d_chk$d_days == 0, na.rm = TRUE)}/{nrow(d_chk)} same day, ",
         "{sum(abs(d_chk$d_days) <= 1, na.rm = TRUE)} within 1 day"), "\n")
date agreement with matched occupation: 201/306 same day, 306 within 1 day 
Code
d_ts_keyed |>
  filter(position_source == "unmatched") |>
  select(cruise_orig, cruise_key, line, station, order_occ, date) |>
  datatable(caption = "Samples with no matching CalCOFI station occupation (Q09)",
            options = list(dom = "t"), rownames = FALSE)

6 Build Sample + Measurement Tables

Counts go long, one measurement per row, with the sorter’s free-text identification note kept beside the count it describes. Per-individual carapace lengths are their own table: the source cell holds one length per megalopa found ("6.0mm, 6.5mm, 7.0mm, 7.0mm"), which is sub-occurrence detail, not a scalar.

Counts are recorded as found in the examined aliquot and are deliberately not standardized — which matches the author’s stated choice (see the Notes sheet above): the aliquot is context for interpreting an absence, not a correction factor, and no filtered-water volume exists in the source to make a density anyway.

Code
d_sample <- d_ts_keyed |>
  mutate(dungeness_sample_id = row_number(), .before = 1) |>
  transmute(
    dungeness_sample_id, cruise_orig, cruise_key, ship_key,
    line, station, site_key, order_occ = as.integer(order_occ),
    date,
    datetime_start_utc = as_datetime(date) + dseconds(coalesce(time_sec, 0L)),
    latitude, longitude, position_source, site_sample_key,
    aliquot, volume_ml,
    sorted_by = "Angela Klemmedson",
    comments)
dbWriteTable(con, "dungeness_sample", as.data.frame(d_sample), overwrite = TRUE)
cat(glue("dungeness_sample: {nrow(d_sample)} rows"), "\n")
dungeness_sample: 310 rows 
Code
# (count column, free-text description column) -> canonical measurement_type
meas_map <- tribble(
  ~measurement_type,    ~n_col,             ~desc_col,
  "megalopae_magister", "n_mega_magister",  NA_character_,
  "megalopae_other",    "n_mega_other",     "mega_other_desc",
  "zoea_cancer",        "n_zoea_cancer",    "zoea_cancer_desc",
  "zoea_other",         "n_zoea_other",     "zoea_other_desc")

d_meas <- pmap_dfr(meas_map, function(measurement_type, n_col, desc_col) {
  tibble(
    dungeness_sample_id = d_sample$dungeness_sample_id,
    measurement_type    = measurement_type,
    measurement_value   = d_ts_keyed[[n_col]],
    taxa_description    = if (is.na(desc_col)) NA_character_
                          else d_ts_keyed[[desc_col]])
}) |>
  # a blank source cell is "not recorded", which is not the same as a counted
  # zero — keep the row and flag it rather than dropping or zero-filling it
  mutate(measurement_qual = if_else(
    is.na(measurement_value), "blank in source", NA_character_)) |>
  arrange(dungeness_sample_id, measurement_type) |>
  mutate(dungeness_measurement_id = row_number(), .before = 1)
dbWriteTable(con, "dungeness_measurement", as.data.frame(d_meas), overwrite = TRUE)

# nothing may be lost or invented in the pivot
stopifnot(
  "measurement rows must be samples x types" =
    nrow(d_meas) == nrow(d_sample) * nrow(meas_map),
  "pivoted counts must sum to the source totals" =
    sum(d_meas$measurement_value, na.rm = TRUE) ==
    sum(d_ts_keyed$n_mega_magister, d_ts_keyed$n_mega_other,
        d_ts_keyed$n_zoea_cancer,   d_ts_keyed$n_zoea_other, na.rm = TRUE))
cat(glue("dungeness_measurement: {nrow(d_meas)} rows ",
         "({sum(!is.na(d_meas$measurement_qual))} blank in source)"), "\n")
dungeness_measurement: 1240 rows (5 blank in source) 
Code
d_carapace <- parse_carapace_lengths(d_ts_keyed$carapace_len_txt) |>
  mutate(dungeness_sample_id = d_sample$dungeness_sample_id[row_index]) |>
  select(dungeness_sample_id, individual_num, carapace_length_mm) |>
  arrange(dungeness_sample_id, individual_num) |>
  mutate(dungeness_carapace_id = row_number(), .before = 1)
dbWriteTable(con, "dungeness_carapace", as.data.frame(d_carapace), overwrite = TRUE)

# one measured individual per megalopa counted — this is the check that the
# carapace cell was split correctly rather than plausibly
n_mag <- sum(d_ts_keyed$n_mega_magister, na.rm = TRUE)
stopifnot("one carapace length per M. magister megalopa counted" =
            nrow(d_carapace) == n_mag)
cat(glue("dungeness_carapace: {nrow(d_carapace)} individuals, ",
         "{min(d_carapace$carapace_length_mm)}-",
         "{max(d_carapace$carapace_length_mm)} mm"), "\n")
dungeness_carapace: 24 individuals, 5.8-7.3 mm 

7 Build Sorting Log + Specimen Verification

The sorting log is effort, not counts: which archived samples had been looked at as of 2012-02-15 and which had not. It carries its own coordinates, so no station matching is needed — but it reaches far north and offshore of the CalCOFI grid, so most rows get no grid_key.

It records no ship, so cruise_key is assigned only where the parsed year-month matches exactly one cruise in the reference.

Code
d_ym_lookup <- dbGetQuery(con, "
  SELECT strftime(date_ym, '%Y-%m') AS cruise_ym, cruise_key
  FROM cruise") |>
  count(cruise_ym, cruise_key) |>
  add_count(cruise_ym, name = "n_cruises") |>
  filter(n_cruises == 1) |>
  select(cruise_ym, cruise_key)

d_sorting <- d_log |>
  mutate(
    # expedition labels are inconsistent ("8803", "CALCOFI 0404", "BODEGA BAY");
    # take a trailing YYMM where one exists, otherwise no cruise at all
    yymm      = str_match(expedition_orig, "(\\d{2})(\\d{2})[A-Z]?$")[, 1],
    yy        = as.integer(str_sub(yymm, 1, 2)),
    mm        = as.integer(str_sub(yymm, 3, 4)),
    # this log spans 1949-2009: resolve the 2-digit year against the row's own
    # date rather than a fixed pivot
    cruise_ym = if_else(
      is.na(yymm) | is.na(date), NA_character_,
      sprintf("%04d-%02d", 100L * (year(date) %/% 100L) + yy, mm)),
    # one row records longitude +138.483 where every other is negative; flag it
    # rather than silently negating (Q08)
    longitude = if_else(!is.na(longitude) & longitude > 0, NA_real_, longitude)) |>
  left_join(d_ym_lookup, by = "cruise_ym") |>
  transmute(
    sorting_status, expedition_orig, cruise_ym, cruise_key,
    line, station, site_key, latitude, longitude,
    date,
    datetime_start_utc = as_datetime(date) + dseconds(coalesce(time_start_sec, 0L)),
    datetime_end_utc   = as_datetime(date) + dseconds(coalesce(time_end_sec, 0L)),
    max_depth_m, net, mesh_mm, tow_type, preservative,
    sorted_by = "Emily Jones") |>
  arrange(date, site_key) |>
  mutate(dungeness_sorting_log_id = row_number(), .before = 1)
dbWriteTable(con, "dungeness_sorting_log", as.data.frame(d_sorting), overwrite = TRUE)

cat(glue("dungeness_sorting_log: {nrow(d_sorting)} rows ",
         "({sum(d_sorting$sorting_status == 'sorted')} sorted, ",
         "{sum(d_sorting$sorting_status == 'unsorted')} unsorted); ",
         "cruise_key resolved for {sum(!is.na(d_sorting$cruise_key))}; ",
         "dates {format(min(d_sorting$date))} to {format(max(d_sorting$date))}"), "\n")
dungeness_sorting_log: 2011 rows (216 sorted, 1795 unsorted); cruise_key resolved for 372; dates 1949-04-02 to 2009-04-19 
Code
d_specimen <- d_spec |>
  transmute(
    specimen_num, cruise_orig, line, station, site_key, date,
    datetime_start_utc = as_datetime(date) + dseconds(coalesce(time_sec, 0L)),
    is_magister, carapace_length_mm, notes)
dbWriteTable(con, "dungeness_specimen", as.data.frame(d_specimen), overwrite = TRUE)
cat(glue("dungeness_specimen: {nrow(d_specimen)} specimens, ",
         "{sum(d_specimen$is_magister)} confirmed M. magister"), "\n")
dungeness_specimen: 5 specimens, 2 confirmed M. magister 

8 Enforce Column Types

Before any geometry exists: enforce_column_types() rewrites columns, and a table carrying a GEOMETRY column is the one thing DuckDB ≥ 1.5.1 will not let you rewrite (see the known-bug note in CLAUDE.md).

Code
d_flds_rd <- read_csv(here(glue("metadata/{provider}/{dataset}/flds_redefine.csv")))
enforce_column_types(con, d_flds_rd = d_flds_rd, tables = dcr_tbls)
# A tibble: 1 × 5
  table            column    from_type to_type  success
  <chr>            <chr>     <chr>     <chr>    <lgl>  
1 dungeness_sample order_occ INTEGER   SMALLINT TRUE   

9 Spatial

geom and grid_key come last, so every other column value is already settled in R and nothing has to UPDATE a table that has geometry on it.

Code
for (tbl in c("dungeness_sample", "dungeness_sorting_log")) {
  add_point_geom(con, tbl, lon_col = "longitude", lat_col = "latitude")
  assign_grid_key(con, tbl)
}

d_spatial_cov <- dbGetQuery(con, "
  SELECT 'dungeness_sample'      AS tbl, COUNT(*) AS n,
         COUNT(latitude)         AS n_positioned,
         COUNT(grid_key)         AS n_in_grid FROM dungeness_sample
  UNION ALL
  SELECT 'dungeness_sorting_log', COUNT(*), COUNT(latitude), COUNT(grid_key)
  FROM dungeness_sorting_log")

# the borrowed swfsc_ichthyo reference VIEWs have done their job (cruise/ship
# resolution, station match, grid assignment). Drop them so validation and the
# metadata sidecar see only this dataset's tables — otherwise the checks below
# report NULLs in ichthyo's 61k-row `sample`, which is not this ingest's business.
for (v in c("sample", "ship", "cruise", "grid"))
  dbExecute(con, glue("DROP VIEW IF EXISTS {v}"))

d_spatial_cov |>
  datatable(caption = "Position + grid coverage", options = list(dom = "t"),
            rownames = FALSE)

10 Measurement Vocabulary + Dataset Metadata

The source’s four count columns are not measurement types. Baking the taxon into the type name (megalopae_magister) is exactly the anti-pattern the core model exists to remove: in obs the quantity is abundance and the organism is taxon_key. So the four raw labels became entries in metadata/measurement_taxon.csv, which maps each to (measurement_type, taxon_key, life_stage) — see “Emit Core Tables” below.

That leaves two genuinely new canonical quantities, carapace_length and settled_volume_ml. They were staged in metadata/cdfw/dungeness-crab/measurement_type_new.csv while this dataset was held out — the shared registry ships wholesale into the release, so appending them early would have published two types with no observations behind them. They moved into metadata/measurement_type.csv on 2026-08-14, in the change that put this dataset in the release, and the staging file is gone. The aliquot fraction maps onto the existing prop_sorted, so it needs nothing new.

Code
# `carapace_length` and `settled_volume_ml` were staged in
# measurement_type_new.csv while this dataset was held out of the release, since
# the shared registry ships wholesale and would have published two types with no
# observations. They moved into metadata/measurement_type.csv (via
# register_measurement_types(), which is append-only and always writes na = "")
# in the change that put this dataset in the release, so there is nothing left to
# union — the shared registry is now the whole story.
d_meas_type <- read_measurement_type(here("metadata/measurement_type.csv"))
stopifnot(
  "the two formerly-staged types must now be in the shared registry" =
    all(c("carapace_length", "settled_volume_ml") %in% d_meas_type$measurement_type))
dbWriteTable(con, "measurement_type", as.data.frame(d_meas_type), overwrite = TRUE)

this_provider <- provider; this_dataset <- dataset   # `provider`/`dataset` are also column names
d_dataset <- ingest_yaml_to_dataset_df(read_ingest_yaml(here())) |>
  filter(provider == this_provider, dataset == this_dataset)
stopifnot("this dataset must appear in the ingest YAML registry" = nrow(d_dataset) == 1)
dbWriteTable(con, "dataset", as.data.frame(d_dataset), overwrite = TRUE)

d_meas_type |>
  filter(`_source_datasets` == glue("{provider}_{dataset}")) |>
  select(measurement_type, description, units, grain) |>
  datatable(caption = "Canonical measurement types contributed by this dataset",
            options = list(dom = "t"), rownames = FALSE)

11 Emit Core Tables

Project this dataset into the shared consolidated core model (RUNBOOK.md §3b, design_env-bio-consolidation.md). These core tables are this ingest’s published output — release_database.qmd concatenates per-dataset shards rather than re-deriving them — and the per-dataset tables survive as compat VIEWs.

This uses the generic append_* helpers with plain SQL, so no calcofi4db change is needed to add a dataset — a dataset’s projection belongs in the notebook that owns it. The package used to carry a switch(dataset_key, …) arm per core table; those were deleted in calcofi4db 3.0.0, because the release kept a second copy of every arm and the two drifted into four silent data errors.

Two modelling decisions worth review:

  • sample_type = 'subsample' is new vocabulary (existing values: bottle, cast, net, region_pool, site, tow, transect, underway). The grain genuinely is a lab subsample of an archived net catch — finer than the tow, since three station occupations were examined twice as separate removal vials — so none of the existing values fits. Flagged as Q05.
  • parent_sample_key points at the matched swfsc_ichthyo site occupation, a deliberate cross-dataset edge in the sample adjacency list. This is the payoff of the station match: it makes crab larvae joinable to CTD and bottle environment through the shared physical station occupation instead of stranding them on their own island.

The 2,011-row sorting log becomes sample rows (sample_type = 'tow'), and the sorted/unsorted distinction becomes the presence or absence of an obs row:

  • 216 sorted → one obs row for M. magister with measurement_value = 0. “Examined, none found” is a real observation, and a zero is how you record it.
  • 1,795 unsortedsample row, no obs. We have not looked, which is not the same as looking and finding nothing.

That distinction is the entire scientific content of the log, and encoding it this way means it needs no sorting_status column of its own — which is what lets this ingest publish the core and nothing else (see “Write Outputs”). Note the zeros are safe with respect to the open Q02: that question doubts the positive Reilly-era “Cancer magister” labels, not Jones’s report of finding none.

Code
ds_key <- glue("{provider}_{dataset}")

# the taxon crosswalk resolves each raw count label to (measurement_type,
# taxon_key, life_stage). Restricted to this dataset so the helpers below build
# only our slice of the shared taxa references.
# worms_id/itis_id MUST be integer: read as double, CAST(... AS VARCHAR) yields
# "440388.0" and every taxon_key silently fails to resolve against `taxon`
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"))
stopifnot("crosswalk must cover all four raw count labels" =
            setequal(mt_taxon$raw_measurement_type[mt_taxon$target == "obs"],
                     c("megalopae_magister", "megalopae_other",
                       "zoea_cancer", "zoea_other")))
# taxa references — generic helpers, driven by the crosswalk rather than by a
# per-dataset vocabulary table

# 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_tx_group <- build_taxon_group(con,     mt_taxon, tx_over)
# stage the crosswalk WITH its derived taxon_key, rather than dbWriteTable()-ing
# the raw CSV (which has no taxon_key column) and hand-rolling
# 'worms:' || worms_id in each arm — that inline string mis-keys any
# ITIS-resolved taxon and silently produces a taxon_key that joins to nothing
ensure_measurement_taxon(con, mt_taxon, dataset_key = ds_key)

sub_key <- glue("'{ds_key}:subsample:' || CAST(dungeness_sample_id AS VARCHAR)")

# 1. sample — examined subsamples, parented to the matched station occupation
append_sample(con, glue("
  SELECT {sub_key}, 'subsample', site_sample_key,
         COALESCE(site_sample_key, {sub_key}),
         '{ds_key}', grid_key, site_key, cruise_key, order_occ,
         latitude, longitude, CAST(datetime_start_utc AS TIMESTAMP),
         NULL::DOUBLE, NULL::DOUBLE, NULL::VARCHAR
  FROM dungeness_sample"))

# 1b. sample — sorting-log tows (effort/absence; no obs attach to these)
append_sample(con, glue("
  SELECT '{ds_key}:tow:' || CAST(dungeness_sorting_log_id AS VARCHAR),
         'tow', NULL::VARCHAR,
         '{ds_key}:tow:' || CAST(dungeness_sorting_log_id AS VARCHAR),
         '{ds_key}', grid_key, site_key, cruise_key, NULL::INTEGER,
         latitude, longitude, CAST(datetime_start_utc AS TIMESTAMP),
         0::DOUBLE, max_depth_m, tow_type
  FROM dungeness_sorting_log"))

# 2. obs — occurrence headline. Because the taxon now lives in taxon_key, the
#    four source columns collapse to ONE measurement_type across four
#    taxon_key/life_stage combinations.
append_obs(con, glue("
  SELECT 'bio', '{ds_key}',
         '{ds_key}:subsample:' || CAST(s.dungeness_sample_id AS VARCHAR),
         s.grid_key, s.cruise_key, s.latitude, s.longitude,
         CAST(s.datetime_start_utc AS TIMESTAMP),
         NULL::DOUBLE, NULL::DOUBLE,
         mx.taxon_key,
         mx.life_stage, mx.measurement_type, m.measurement_value,
         m.measurement_qual, NULL::DOUBLE
  FROM dungeness_measurement m
  JOIN dungeness_sample s USING (dungeness_sample_id)
  JOIN _measurement_taxon mx
    ON mx.raw_measurement_type = m.measurement_type AND mx.target = 'obs'"))

# 2b. obs — the sorting log's ABSENCES. A sorted sample with no M. magister is a
#     zero-valued occurrence, not a missing row; an unsorted sample gets nothing,
#     because "not examined" and "examined, none found" are different facts.
append_obs(con, glue("
  SELECT 'bio', '{ds_key}',
         '{ds_key}:tow:' || CAST(l.dungeness_sorting_log_id AS VARCHAR),
         l.grid_key, l.cruise_key, l.latitude, l.longitude,
         CAST(l.datetime_start_utc AS TIMESTAMP),
         0::DOUBLE, l.max_depth_m,
         mx.taxon_key,
         mx.life_stage, mx.measurement_type, 0::DOUBLE,
         'examined, none found (E. Jones sorting log, as of 2012-02-15)',
         NULL::DOUBLE
  FROM dungeness_sorting_log l
  CROSS JOIN (SELECT * FROM _measurement_taxon
              WHERE raw_measurement_type = 'megalopae_magister') mx
  WHERE l.sorting_status = 'sorted'"))

# 3. obs_attribute — per-individual carapace lengths within the M. magister
#    occurrence: one row per measured megalopa, bin_value IS the length
append_obs_attribute(con, glue("
  SELECT '{ds_key}',
         '{ds_key}:subsample:' || CAST(c.dungeness_sample_id AS VARCHAR),
         mx.taxon_key, mx.life_stage,
         mx.measurement_type, c.carapace_length_mm,
         NULL::VARCHAR, 1::BIGINT, NULL::VARCHAR
  FROM dungeness_carapace c
  CROSS JOIN (SELECT * FROM _measurement_taxon
              WHERE target = 'attribute' AND measurement_type = 'carapace_length') mx"))

# 4. sample_measurement — event-level effort/context for the examined subsamples
append_sample_measurement(con, glue("
  SELECT '{ds_key}:subsample:' || CAST(dungeness_sample_id AS VARCHAR),
         '{ds_key}', mt, mv, NULL::VARCHAR
  FROM (
    SELECT dungeness_sample_id, 'prop_sorted' AS mt, aliquot AS mv FROM dungeness_sample
    UNION ALL
    SELECT dungeness_sample_id, 'settled_volume_ml', volume_ml FROM dungeness_sample)
  WHERE mv IS NOT NULL"))

core_n <- 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_taxon, dataset_taxon = n_ds_taxon, taxon_group = n_tx_group)
cat(glue("core projection — ",
         "{paste(names(core_n), unlist(core_n), sep = '=', collapse = '  ')}"), "\n")
core projection — sample=2321  obs=1456  obs_attribute=24  sample_measurement=617  taxon=17  dataset_taxon=3  taxon_group=0 

The projection must be lossless and must not invent anything, so assert it rather than eyeball it:

Code
n_sub <- dbGetQuery(con, "SELECT COUNT(*) FROM sample WHERE sample_type='subsample'")[[1]]
n_tow <- dbGetQuery(con, "SELECT COUNT(*) FROM sample WHERE sample_type='tow'")[[1]]
stopifnot(
  "one core sample per examined subsample" = n_sub == nrow(d_sample),
  "one core sample per sorting-log tow"    = n_tow == nrow(d_sorting),
  "sample_key must be unique within the shard" =
    dbGetQuery(con, "SELECT COUNT(*) FROM (SELECT sample_key FROM sample
                     GROUP BY 1 HAVING COUNT(*) > 1)")[[1]] == 0,
  # the time series' counts, plus one zero-valued absence per sorted log sample
  "obs must carry every source count row plus the sorting-log absences" =
    core_n$obs == nrow(d_meas) + sum(d_sorting$sorting_status == "sorted"),
  "obs totals must equal the source totals (absences add zero)" =
    dbGetQuery(con, "SELECT SUM(measurement_value) FROM obs")[[1]] ==
    sum(d_meas$measurement_value, na.rm = TRUE),
  "every sorted log sample must have exactly one absence obs" =
    dbGetQuery(con, "
      SELECT COUNT(*) FROM obs o JOIN sample s USING (sample_key)
      WHERE s.sample_type = 'tow'")[[1]] ==
      sum(d_sorting$sorting_status == "sorted"),
  "unsorted log samples must carry no obs" =
    dbGetQuery(con, glue("
      SELECT COUNT(*) FROM obs o
      WHERE o.sample_key IN (
        SELECT '{ds_key}:tow:' || CAST(dungeness_sorting_log_id AS VARCHAR)
        FROM dungeness_sorting_log WHERE sorting_status = 'unsorted')"))[[1]] == 0,
  "sorting-log absences must all be zero" =
    dbGetQuery(con, "
      SELECT COUNT(*) FROM obs o JOIN sample s USING (sample_key)
      WHERE s.sample_type = 'tow' AND o.measurement_value <> 0")[[1]] == 0,
  "every obs must resolve a taxon_key" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs WHERE taxon_key IS NULL")[[1]] == 0,
  # NOT-NULL is not enough: a numeric-typed worms_id renders as "440388.0", which
  # is non-NULL and joins to nothing. Assert the FK actually resolves.
  "every obs.taxon_key must resolve in taxon" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs o LEFT JOIN taxon t USING (taxon_key)
                     WHERE t.taxon_key IS NULL")[[1]] == 0,
  "every obs_attribute.taxon_key must resolve in taxon" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs_attribute a
                     LEFT JOIN taxon t USING (taxon_key)
                     WHERE t.taxon_key IS NULL")[[1]] == 0,
  "every obs.measurement_type must resolve in measurement_type" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs o
                     LEFT JOIN measurement_type t USING (measurement_type)
                     WHERE t.measurement_type IS NULL")[[1]] == 0,
  "every obs.sample_key must resolve in sample" =
    dbGetQuery(con, "SELECT COUNT(*) FROM obs o LEFT JOIN sample s USING (sample_key)
                     WHERE s.sample_key IS NULL")[[1]] == 0,
  "one obs_attribute row per measured individual" =
    core_n$obs_attribute == nrow(d_carapace),
  # the M. magister headline and its carapace attribution must agree per sample
  "carapace individuals must match the magister count per sample" =
    dbGetQuery(con, "
      WITH a AS (SELECT sample_key, SUM(count) s FROM obs_attribute
                 WHERE measurement_type='carapace_length' GROUP BY 1),
           o AS (SELECT sample_key, SUM(measurement_value) v FROM obs
                 WHERE taxon_key='worms:440388' AND measurement_type='abundance'
                 GROUP BY 1)
      SELECT COUNT(*) FROM a JOIN o USING (sample_key) WHERE a.s <> o.v")[[1]] == 0,
  # the headline count must come from the time series alone, not the absences
  "the 24 megalopae must all come from the examined subsamples" =
    dbGetQuery(con, "
      SELECT SUM(o.measurement_value) FROM obs o JOIN sample s USING (sample_key)
      WHERE s.sample_type = 'subsample' AND o.taxon_key = 'worms:440388'")[[1]] == 24)
cat("core parity: all assertions passed\n")
core parity: all assertions passed
Code
dbGetQuery(con, "
  SELECT COALESCE(t.scientific_name, o.taxon_key) AS taxon, o.life_stage,
         o.measurement_type, COUNT(*) AS n_obs, SUM(o.measurement_value) AS total
  FROM obs o LEFT JOIN taxon t USING (taxon_key)
  GROUP BY 1,2,3 ORDER BY total DESC") |>
  datatable(caption = "obs by resolved taxon — the four source columns, collapsed",
            options = list(dom = "t"), rownames = FALSE)

The per-dataset tables the core fully models become VIEWs over it, so the previews below and any ad-hoc query keep working against the names this notebook built. The originals are kept as *_src for the source ERD.

These VIEWs are a convenience inside this notebook only — they are not published. Nothing per-dataset is: see “Write Outputs”.

Code
for (t in c("dungeness_measurement", "dungeness_carapace")) {
  invisible(dbExecute(con, glue("DROP TABLE IF EXISTS {t}_src")))
  invisible(dbExecute(con, glue("ALTER TABLE {t} RENAME TO {t}_src")))
}

# scoped to the examined subsamples: the 216 sorting-log absence rows are also
# `obs` for this dataset, but they were never part of dungeness_measurement and
# their sample_key resolves to a sorting-log id, not a dungeness_sample_id
invisible(dbExecute(con, glue("
  CREATE OR REPLACE VIEW dungeness_measurement AS
  SELECT obs_id AS dungeness_measurement_id,
         CAST(split_part(sample_key, ':', 3) AS INTEGER) AS dungeness_sample_id,
         measurement_type, measurement_value, measurement_qual
  FROM obs
  WHERE dataset_key = '{ds_key}' AND split_part(sample_key, ':', 2) = 'subsample'")))
invisible(dbExecute(con, glue("
  CREATE OR REPLACE VIEW dungeness_carapace AS
  SELECT obs_attribute_id AS dungeness_carapace_id,
         CAST(split_part(sample_key, ':', 3) AS INTEGER) AS dungeness_sample_id,
         ROW_NUMBER() OVER (PARTITION BY sample_key ORDER BY obs_attribute_id)
           AS individual_num,
         bin_value AS carapace_length_mm
  FROM obs_attribute
  WHERE dataset_key = '{ds_key}' AND measurement_type = 'carapace_length'")))

n_view <- dbGetQuery(con, "SELECT COUNT(*) FROM dungeness_measurement")[[1]]
n_cara <- dbGetQuery(con, "SELECT COUNT(*) FROM dungeness_carapace")[[1]]
stopifnot(
  "compat view must expose every source measurement row" = n_view == nrow(d_meas),
  "compat view must expose every obs_attribute row" = n_cara == core_n$obs_attribute)
cat(glue("compat views over core — dungeness_measurement {n_view} rows ",
         "(the {core_n$obs - n_view} sorting-log absences are obs but not part of ",
         "this view), dungeness_carapace {n_cara} rows"), "\n")
compat views over core — dungeness_measurement 1240 rows (the 216 sorting-log absences are obs but not part of this view), dungeness_carapace 24 rows 

12 Schema

Two diagrams, because they answer different questions: what the workbooks look like, and what this ingest actually publishes.

12.1 What gets published — the core

This is the schema a consumer sees. It is the only thing written to parquet.

Code
core_tbls <- core_output_tables(con, extra = "measurement_type")
cc_erd(con, tables = core_tbls, rels = core_relationships(core_tbls),
       colors = list(
         lightblue   = c("sample", "obs"),
         lightgreen  = c("obs_attribute", "sample_measurement"),
         lightyellow = c("taxon", "dataset_taxon", "measurement_type")))

12.2 How the source maps onto it

The workbooks are three unrelated spreadsheets; the core is five related tables. Neither diagram alone shows how one becomes the other, so state it explicitly — this is the part a reader actually needs in order to trust the numbers.

12.3 The source shape

For reference only — it documents the wrangling above, and none of it is published.

Code
dcr_rels <- list(
  primary_keys = list(
    dungeness_sample      = "dungeness_sample_id",
    dungeness_sorting_log = "dungeness_sorting_log_id",
    dungeness_specimen    = "specimen_num",
    measurement_type      = "measurement_type"),
  foreign_keys = list(
    list(table = "dungeness_measurement_src", column = "dungeness_sample_id",
         ref_table = "dungeness_sample", ref_column = "dungeness_sample_id"),
    list(table = "dungeness_carapace_src", column = "dungeness_sample_id",
         ref_table = "dungeness_sample", ref_column = "dungeness_sample_id")))

cc_erd(con,
       tables = c("dungeness_sample", "dungeness_measurement_src",
                  "dungeness_carapace_src", "dungeness_sorting_log",
                  "dungeness_specimen", "dataset"),
       rels = dcr_rels,
       colors = list(
         lightblue  = c("dungeness_sample", "dungeness_measurement_src",
                        "dungeness_carapace_src"),
         lightgreen = c("dungeness_sorting_log", "dungeness_specimen"),
         white      = "dataset"))

13 Validate

Code
results <- validate_for_release(con, checks = "all", strict = FALSE)

# `validate_for_release()`'s null check treats EVERY column ending _id/_key/_uuid
# as required, which is a heuristic, not this dataset's contract: `parent_taxon_key`
# is legitimately NULL for a root taxon, `cruise_key` for a log entry that names no
# ship, `grid_key` for a station outside the CalCOFI grid. So its verdict is
# "FAILED" for reasons that are mostly not defects.
#
# Reporting that as "FAILED, but all expected" is worthless — it is exactly how a
# real defect hides in a wall of accepted noise. Instead: every nullable case is
# declared below WITH ITS COUNT AND REASON, and anything that is not on this list,
# or whose count has moved, is a hard failure. That turns a vague verdict into a
# contract that breaks when the data changes.
nullable <- tribble(
  ~table,                  ~column,             ~n,   ~reason,
  "dungeness_sample",      "site_sample_key",   4L,   "no matching CalCOFI station occupation (Q09)",
  "dungeness_sample",      "grid_key",          4L,   "same 4 samples: no position, so no grid cell",
  "dungeness_sorting_log", "cruise_key",        1639L,"log records no ship; cruise resolves only where YYYY-MM is unambiguous",
  "dungeness_sorting_log", "site_key",          77L,  "no line/station in the source row",
  "dungeness_sorting_log", "grid_key",          77L,  "same 77 rows: north of or offshore from the CalCOFI grid",
  "sample",                "parent_sample_key", 2015L,"2011 sorting-log tows are roots + the 4 unmatched subsamples",
  "sample",                "site_key",          77L,  "inherited from the sorting log",
  "sample",                "cruise_key",        1639L,"inherited from the sorting log",
  "sample",                "grid_key",          81L,  "4 unmatched subsamples + 77 out-of-grid log tows",
  "obs",                   "grid_key",          30L,  "16 from the 4 unmatched subsamples x 4 types + 14 out-of-grid absences",
  "obs",                   "cruise_key",        87L,  "absence rows whose log entry has no resolvable cruise",
  "obs",                   "hex_id",            16L,  "H3 needs a position; the 4 unmatched subsamples have none",
  # taxon: 17 rows, not 3 — ensure_taxon_lineage() adds this dataset's 3 taxa
  # PLUS their 14 WoRMS lineage ancestors, which have to be rows of their own or
  # parent_taxon_key chains dead-end and "all Decapoda" matches nothing.
  # `rank`/classification are no longer on this list: the lineage fetch fills them.
  "taxon",                 "ncbi_id",           17L,  "never populated by ANY dataset — the column exists, nothing fills it",
  "taxon",                 "inat_id",           17L,  "never populated by ANY dataset",
  # 17 -> 15 -> 6. Nothing asked WoRMS for a cross-authority id at all; then
  # ensure_taxon_xref() asked for this dataset's own taxa; then (calcofi4db
  # 3.8.0) ensure_taxon_lineage() started topping the cross-reference up for the
  # lineage ANCESTORS it discovers, which the xref step runs too early to see.
  # The 6 that remain are taxa WoRMS links no TSN for — mostly the ranks ITIS
  # does not share (Section, Subsection, Superclass).
  "taxon",                 "itis_id",           6L,   "WoRMS links no ITIS TSN for these — ranks ITIS does not carry (Section/Subsection) or unmatched nodes",
  "taxon",                 "gbif_id",           17L,  "nothing asks GBIF — unlike itis_id, there is no GBIF crosswalk in the xref",
  "taxon",                 "parent_taxon_key",  1L,   "the root of the lineage (Biota) has no parent — 0 dangling parents below it")

nulls <- results$checks |>
  filter(check == "nulls", status != "PASS") |>
  mutate(table = table,
         column = str_match(message, "column '([^']+)'")[, 2],
         n      = as.integer(str_match(message, "has (\\d+) NULL")[, 2])) |>
  select(table, column, n)

declared   <- nullable |> select(table, column, n_expected = n)
reconciled <- full_join(nulls, declared, by = c("table", "column"))
unexplained <- reconciled |> filter(is.na(n_expected))
moved       <- reconciled |> filter(!is.na(n_expected), !is.na(n), n != n_expected)
resolved    <- reconciled |> filter(!is.na(n_expected), is.na(n))

if (nrow(unexplained))
  print(unexplained)
if (nrow(moved))
  print(moved)
stopifnot(
  "a NULL appeared in a column with no declared reason — investigate, do not add it to the list reflexively" =
    nrow(unexplained) == 0,
  "a declared NULL count has changed — the data moved, so re-derive the reason" =
    nrow(moved) == 0)
if (nrow(resolved))
  cat(glue("{nrow(resolved)} declared nullable case(s) no longer occur — ",
           "prune them from `nullable`: ",
           "{paste(resolved$table, resolved$column, sep = '.', collapse = ', ')}"), "\n")

# The lineage gap this ingest used to assert (all 3 taxa with rank and
# parent_taxon_key NULL, so "all Decapoda" matched nothing) is FIXED:
# ensure_taxon_lineage() fetches the WoRMS classification above. Assert the fix
# rather than the gap — including that Metacarcinus magister is reachable from
# Decapoda, which is the rollup that was broken.
n_taxon_no_lineage <- dbGetQuery(con, "
  SELECT COUNT(*) FROM taxon WHERE rank IS NULL AND parent_taxon_key IS NULL")[[1]]
n_decapoda <- dbGetQuery(con, "
  WITH RECURSIVE d AS (
    SELECT taxon_key FROM taxon WHERE scientific_name = 'Decapoda'
    UNION ALL
    SELECT t.taxon_key FROM taxon t JOIN d ON t.parent_taxon_key = d.taxon_key)
  SELECT COUNT(*) FROM d")[[1]]
stopifnot(
  "every taxon must carry rank + parent_taxon_key (the lineage fetch)" =
    n_taxon_no_lineage == 0,
  "M. magister must be reachable from Decapoda by walking parent_taxon_key" =
    n_decapoda > 1)

cat(glue("validate_for_release: {ifelse(results$passed, 'PASSED', 'FAILED')} ",
         "— its null heuristic treats every *_id/_key column as required, so that ",
         "verdict is not this dataset's contract. {nrow(nulls)} null case(s) ",
         "reconciled against {nrow(nullable)} declared, 0 unexplained."), "\n")
validate_for_release: FAILED — its null heuristic treats every *_id/_key column as required, so that verdict is not this dataset's contract. 17 null case(s) reconciled against 17 declared, 0 unexplained. 
Code
cat(glue("taxon lineage: {n_taxon_no_lineage} taxa without rank/parent_taxon_key; ",
         "{n_decapoda} taxa reachable from Decapoda by walking parent_taxon_key ",
         "(the rollup that used to return nothing)."), "\n")
taxon lineage: 0 taxa without rank/parent_taxon_key; 10 taxa reachable from Decapoda by walking parent_taxon_key (the rollup that used to return nothing). 
Code
# referential integrity of the two FKs this ingest owns
n_orphan_meas <- dbGetQuery(con, "
  SELECT COUNT(*) FROM dungeness_measurement m
  LEFT JOIN dungeness_sample s USING (dungeness_sample_id)
  WHERE s.dungeness_sample_id IS NULL")[[1]]
n_orphan_cara <- dbGetQuery(con, "
  SELECT COUNT(*) FROM dungeness_carapace c
  LEFT JOIN dungeness_sample s USING (dungeness_sample_id)
  WHERE s.dungeness_sample_id IS NULL")[[1]]
n_orphan_type <- dbGetQuery(con, "
  SELECT COUNT(*) FROM dungeness_measurement m
  LEFT JOIN measurement_type t USING (measurement_type)
  WHERE t.measurement_type IS NULL")[[1]]
# primary keys must be unique
n_dup_pk <- dbGetQuery(con, "
  SELECT COUNT(*) FROM (SELECT dungeness_sample_id FROM dungeness_sample
                        GROUP BY 1 HAVING COUNT(*) > 1)")[[1]]
stopifnot(
  "dungeness_measurement.dungeness_sample_id must resolve" = n_orphan_meas == 0,
  "dungeness_carapace.dungeness_sample_id must resolve"    = n_orphan_cara == 0,
  "dungeness_measurement.measurement_type must resolve"    = n_orphan_type == 0,
  "dungeness_sample_id must be unique"                     = n_dup_pk == 0)
cat("FK + PK integrity: OK\n")
FK + PK integrity: OK
Code
# the headline result of the whole dataset, asserted so a bad re-parse is loud.
# 15 positive ROWS but 14 positive station OCCUPATIONS. The Notes sheet states it
# exactly — "M. magister megalopae found in 14 samples (15 total removal vials)"
# — so the repeat at 1204OS/070.0 055.0/occ 45 (1 megalopa in each row) is a
# second removal vial from one sample, not a duplicated row. The workbook's
# "M. magister values" sheet aggregates the pair, which is why it lists 14.
# asked of the CORE now, via taxon_key — the source's 'megalopae_magister' column
# name no longer exists as a measurement_type, which is the point of the model
# reported per EFFORT, because the two are not the same survey: the 2015 time
# series counted, the 2012 log only recorded that it looked. Lumping them silently
# changes the occurrence rate (15/310 becomes 15/526), so name the scope.
mag <- dbGetQuery(con, "
  SELECT s.sample_type,
         COUNT(*) AS n_examined,
         COUNT(*) FILTER (WHERE o.measurement_value > 0) AS n_positive_rows,
         COUNT(DISTINCT CASE WHEN o.measurement_value > 0
                             THEN s.cruise_key || s.site_key || s.order_occ END)
           AS n_positive_stations,
         SUM(o.measurement_value) AS n_megalopae
  FROM obs o JOIN sample s USING (sample_key)
  WHERE o.taxon_key = 'worms:440388' AND o.life_stage = 'megalopa'
  GROUP BY 1 ORDER BY 1") |>
  (\(d) d[match(c("subsample", "tow"), d$sample_type), ])()
names(mag)[1] <- "effort"
mag$effort <- c("2015 time series (Klemmedson)", "2012 sorting log (Jones)")
mag |> datatable(caption = "M. magister megalopae by effort — the log contributes examined-but-empty samples",
                 options = list(dom = "t"), rownames = FALSE)
Code
mag <- dbGetQuery(con, "
  SELECT COUNT(*) AS n_samples,
         COUNT(*) FILTER (WHERE o.measurement_value > 0) AS n_positive_rows,
         COUNT(DISTINCT CASE WHEN o.measurement_value > 0
                             THEN s.cruise_key || s.site_key || s.order_occ END)
           AS n_positive_stations,
         SUM(o.measurement_value) AS n_megalopae
  FROM obs o JOIN sample s USING (sample_key)
  WHERE o.taxon_key = 'worms:440388' AND o.life_stage = 'megalopa'
    AND s.sample_type = 'subsample'")
stopifnot(
  "24 M. magister megalopae in total"                 = mag$n_megalopae == 24,
  "15 positive rows / 14 positive station occupations" =
    mag$n_positive_rows == 15 && mag$n_positive_stations == 14)
n_log_examined <- sum(d_sorting$sorting_status == "sorted")
cat(glue("M. magister: {mag$n_megalopae} megalopae in {mag$n_positive_rows} of ",
         "{mag$n_samples} samples in the 2015 time series ",
         "({round(100 * mag$n_positive_rows / mag$n_samples, 1)}% occurrence), ",
         "at {mag$n_positive_stations} distinct station occupations. ",
         "Plus {n_log_examined} examined-but-empty samples from the 2012 log, ",
         "for {mag$n_positive_rows}/{mag$n_samples + n_log_examined} ",
         "({round(100 * mag$n_positive_rows / (mag$n_samples + n_log_examined), 1)}%) ",
         "across both efforts"), "\n")
M. magister: 24 megalopae in 15 of 310 samples in the 2015 time series (4.8% occurrence), at 14 distinct station occupations. Plus 216 examined-but-empty samples from the 2012 log, for 15/526 (2.9%) across both efforts 
Code
# replicate subsamples of one station occupation — the reason the sample PK is a
# sequential id rather than (cruise, station, order_occ)
dbGetQuery(con, "
  SELECT cruise_orig, site_key, order_occ, COUNT(*) AS n_rows
  FROM dungeness_sample GROUP BY 1, 2, 3 HAVING COUNT(*) > 1 ORDER BY 1, 2") |>
  datatable(caption = "Station occupations examined more than once (replicate subsamples)",
            options = list(dom = "t"), rownames = FALSE)

14 Preview

Code
dbGetQuery(con, "
  SELECT * EXCLUDE (geom) FROM dungeness_sample
  ORDER BY dungeness_sample_id LIMIT 100") |>
  datatable(caption = "dungeness_sample — first 100", rownames = FALSE, filter = "top")
Code
# read through the CORE, the way a consumer would: obs joined to sample and taxon,
# with the per-individual lengths from obs_attribute
dbGetQuery(con, "
  SELECT s.cruise_key, s.site_key, s.order_occ, CAST(s.datetime AS DATE) AS date,
         round(s.latitude, 3) AS lat, round(s.longitude, 3) AS lon,
         t.scientific_name, o.life_stage, o.measurement_value AS n_magister,
         (SELECT string_agg(CAST(a.bin_value AS VARCHAR), ', ' ORDER BY a.bin_value)
          FROM obs_attribute a
          WHERE a.sample_key = o.sample_key
            AND a.measurement_type = 'carapace_length') AS carapace_mm
  FROM obs o
  JOIN sample s USING (sample_key)
  LEFT JOIN taxon t USING (taxon_key)
  WHERE o.taxon_key = 'worms:440388' AND o.measurement_value > 0
  ORDER BY s.datetime") |>
  datatable(caption = "The 15 removal vials (14 samples) containing M. magister megalopae, read from the core",
            rownames = FALSE)
Code
dbGetQuery(con, "
  SELECT sorting_status, COUNT(*) AS n_samples,
         COUNT(DISTINCT expedition_orig) AS n_expeditions,
         MIN(date) AS date_min, MAX(date) AS date_max,
         COUNT(grid_key) AS n_in_calcofi_grid
  FROM dungeness_sorting_log GROUP BY 1 ORDER BY 1") |>
  datatable(caption = "dungeness_sorting_log — effort by status",
            options = list(dom = "t"), rownames = FALSE)
Code
dbGetQuery(con, "SELECT * FROM dungeness_specimen ORDER BY specimen_num") |>
  datatable(caption = "dungeness_specimen — independent species confirmation",
            options = list(dom = "t"), rownames = FALSE)

15 Questions for Data Providers

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 CDFW Dungeness crab data providers (ranked)")

16 Write Outputs + Upload

Code
dir_create(dir_parquet)

# THE CORE, AND NOTHING ELSE (plus the two shared registries). No source table is
# published: the source workbooks are archived, so a per-dataset parquet copy of
# them is redundant, and consumers must read the core rather than choosing between
# two representations of the same data. The compat VIEWs are views over the core,
# so writing them would duplicate the same bytes under a second name.
tbls_out <- core_output_tables(con, extra = c("measurement_type", "dataset"))
cat(glue("publishing {length(tbls_out)} tables: {paste(tbls_out, collapse = ', ')}"), "\n")
publishing 8 tables: sample, obs, obs_attribute, sample_measurement, taxon, dataset_taxon, measurement_type, dataset 
Code
write_parquet_outputs(
  con = con, output_dir = dir_parquet, tables = tbls_out,
  sort_by = list(obs = c("grid_key", "measurement_type")),
  strip_provenance = FALSE)
# A tibble: 8 × 5
  table               rows file_size path                            partitioned
  <chr>              <dbl>     <dbl> <chr>                           <lgl>      
1 sample              2321     65076 /Users/bbest/_big/calcofi/parq… FALSE      
2 obs                 1456     38484 /Users/bbest/_big/calcofi/parq… FALSE      
3 obs_attribute         24      2249 /Users/bbest/_big/calcofi/parq… FALSE      
4 sample_measurement   617      3027 /Users/bbest/_big/calcofi/parq… FALSE      
5 taxon                 17      4174 /Users/bbest/_big/calcofi/parq… FALSE      
6 dataset_taxon          3      1414 /Users/bbest/_big/calcofi/parq… FALSE      
7 measurement_type     200     12063 measurement_type.parquet        FALSE      
8 dataset                1      5517 dataset.parquet                 FALSE      
Code
# prune parquet this ingest no longer publishes. Moving to the core retired
# dungeness_measurement.parquet / dungeness_carapace.parquet (now compat VIEWs),
# and a stale file left on disk is still picked up by directory scans and by
# sync_to_gcs — so the output dir must match the manifest exactly.
stale <- setdiff(
  tools::file_path_sans_ext(basename(
    list.files(dir_stage, pattern = "[.]parquet$"))), tbls_out)
if (length(stale)) {
  file_delete(file.path(dir_stage, paste0(stale, ".parquet")))
  cat(glue("pruned {length(stale)} stale parquet: {paste(stale, collapse = ', ')}"), "\n")
}

build_relationships_json(
  rels = core_relationships(tbls_out), output_dir = dir_parquet,
  provider = provider, dataset = dataset)
[1] "/Users/bbest/Github/CalCOFI/workflows/data/parquet/cdfw_dungeness-crab/relationships.json"
Code
d_tbls_rd <- read_csv(here(glue("metadata/{provider}/{dataset}/tbls_redefine.csv")))
build_metadata_json(
  con = con, d_tbls_rd = d_tbls_rd, d_flds_rd = d_flds_rd,
  # shared core descriptions first, this dataset's overrides second — without
  # core_dictionary.csv every core table/column ships with an empty description
  metadata_derived_csv = c(
    here("metadata/core_dictionary.csv"),
    here(glue("metadata/{provider}/{dataset}/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 (8 tables, 108 columns) — these render blank in cc_describe_table() / cc_db_catalog():
tables with no description_md: 2    measurement_type, dataset
columns with no description_md: 32    dataset.provider, dataset.dataset, dataset.dataset_name, dataset.dataset_name_short, dataset.category, dataset.color, dataset.description, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others (+20 more)
measurement columns with no units: 26    dataset.dataset_name_short, dataset.category, dataset.color, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others, dataset.tables, dataset.coverage_temporal, dataset.coverage_spatial, dataset.license (+14 more)
  backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run
[1] "/Users/bbest/Github/CalCOFI/workflows/data/parquet/cdfw_dungeness-crab/metadata.json"
Code
if (publish_to_gcs) {
  sync_to_gcs(local_dir = dir_stage, sidecar_dir = dir_parquet, gcs_prefix = glue("ingest/{dir_label}"),
              bucket = "calcofi-db")
} else {
  cat("publish_to_gcs is FALSE — parquet kept local, NOT synced to",
      glue("gs://calcofi-db/ingest/{dir_label}"), "\n")
}
# A tibble: 11 × 4
   file    action    size reason        
   <chr>   <chr>    <dbl> <chr>         
 1 <rsync> uploaded    NA parallel rsync
 2 <rsync> uploaded    NA parallel rsync
 3 <rsync> uploaded    NA parallel rsync
 4 <rsync> uploaded    NA parallel rsync
 5 <rsync> uploaded    NA parallel rsync
 6 <rsync> uploaded    NA parallel rsync
 7 <rsync> uploaded    NA parallel rsync
 8 <rsync> uploaded    NA parallel rsync
 9 <rsync> uploaded    NA parallel rsync
10 <rsync> uploaded    NA parallel rsync
11 <rsync> uploaded    NA parallel rsync
Code
# empty descriptions ship verbatim to consumers, so surface them as TODOs.
# Scoped to every table this ingest PUBLISHES (core + per-dataset) except the two
# shared registries `dataset` and `measurement_type`, which carry no column
# descriptions in any ingest and would be constant noise hiding the real gaps.
m   <- fromJSON(file.path(dir_parquet, "metadata.json"), simplifyVector = FALSE)
gap <- function(x, f) names(Filter(function(e) !nzchar(e[[f]] %||% ""), x))
checked <- setdiff(tbls_out, c("dataset", "measurement_type"))
own <- function(keys) keys[sub("[.].*$", "", keys) %in% checked]

cat(glue("metadata.json gaps in owned tables — tables missing description: ",
         "{length(own(gap(m$tables, 'description_md')))}; ",
         "columns missing description: ",
         "{length(own(gap(m$columns, 'description_md')))}"), "\n")
metadata.json gaps in owned tables — tables missing description: 0; columns missing description: 0 
Code
if (length(own(gap(m$columns, "description_md"))))
  cat("  ", paste(own(gap(m$columns, "description_md")), collapse = ", "), "\n")

17 Not Yet Done

This is a first pass. What remains, roughly in order:

  1. Answer the one remaining blocker, Q01. DONE, 2026-08-13. CDFW (C. Juhasz, via E. Satterthwaite) confirmed the data may be published under CC BY 4.0, with Laura Rogers-Bennett as primary data provider and CDFW as the current citable custodian — the agency was CFG when the 2012 work was commissioned, but the current name is the one a reader can act on. The sorters, E. Jones and A. Klemmedson, are credited in the citation because the examination effort is the dataset. This unblocked both the release and the GCS upload. (Q11, standardization, was already answered — see the Notes sheet above.) Separately, Erin is pursuing publication through the CNRA data portal so there is a citable repository copy to pull from; that is additive and does not gate anything here.
  2. Review the two modelling choices in “Emit Core Tables” (Q05): the new sample_type = 'subsample' vocabulary value, and pointing parent_sample_key at the matched swfsc_ichthyo site occupation.
  3. Populate the taxon lineage. DONE. All three taxa had rank and parent_taxon_key NULL, so a query for “all Decapoda” did not find the M. magister records — and that was true of every dataset whose taxa resolve through metadata/measurement_taxon.csv (swfsc_cufes, calcofi_phyllosoma, cce-lter_euphausiids), because that path carried only worms_id + scientific_name. ensure_taxon_lineage() (called in “Emit Core Tables”) now fetches the WoRMS classification, caches it in metadata/taxon_lineage.csv, and stages it as the hierarchy build_taxon_reference() reads — which also fills kingdom/phylum/class/order_taxon/family, populated by no dataset before, not even ichthyo. “Validate” now asserts the fix (0 taxa without lineage, and M. magister reachable from Decapoda) rather than the gap. Still empty: ncbi_id/inat_id, which no source supplies — kept as declared-but-NULL columns so the release schema does not shift under consumers.
  4. Decide what the sorting log is for. As absence/effort data it is the more scientifically useful of the two efforts — 2,011 samples over 60 years — but only if Q02 settles which historic identifications stand, and Q03 settles whether repeated rows are jars.
  5. Parse the free-text identification notes (Q07) into obs_attribute stage and length bins.
  6. Move measurement_type_new.csv into metadata/measurement_type.csv and drop in_release: false. DONE, 2026-08-14, in one change, alongside deleting the asserted coverage_* keys so coverage is measured like every other dataset and flipping publish_to_gcs.
Code
close_duckdb(con)
cat(glue("Parquet outputs written to: {dir_parquet}"), "\n")
Parquet outputs written to: /Users/bbest/Github/CalCOFI/workflows/data/parquet/cdfw_dungeness-crab