graph LR R[edna.csv<br/>one row per sample, 7 species columns] --> S[edna_sample] R --> D[edna_detection<br/>sample x species, 0/1] D -.sample_label.-> S B[calcofi_bottle<br/>cast + Niskin bottle] -.site + cruise month, depth.-> S
Ingest SIO Cetacean eDNA Detections
Overview
Source: data/edna_data/edna.csv in CalCOFI/marmam-app, read from a pinned commit. One row per NOAA CalCOFI Genomics Program (NCOG) seawater sample screened for cetaceans: 136 rows over 12 cruises (2014-02 to 2016-11), mostly 10 m. Seven species columns (Gg, Tt, Mn, Dc, Lb, Lo, Dd) hold an x where the species’ DNA was detected. eDNA_Process says whether the sample was sequenced (71) or did not amplify (65, “not sequenced PCR negative”).
The app’s edna-processed.csv is not read (a re-typed copy). This is not calpoly_whale-edna (the Zenodo ASV tables behind a whale-density model).
- Provider:
sio(tentative; the samples are NCOG’s, the screening appears to be the Whale Acoustics Lab’s; Q03) - Grain: one water sample (
sample_label, the NCOG nameYYYYMM_LLL.L_SSS.S_depth) - Occurrence:
edna_presence1/0 for each of the seven species on every sequenced sample: 1 = the species’ DNA was detected, 0 = it was not detected in a sample that amplified and was sequenced (a non-detection, not a confirmed absence). A PCR-negative sample (“not sequenced PCR negative”) did not amplify, so it says nothing about any species: it is kept as asamplerow with noobsuntil the provider answers Q04 (Ben, 2026-10-01) - Time: the table gives none. NCOG filters its eDNA from the CTD-rosette Niskin bottles, so each sample takes the time and cruise of its
calcofi_bottlecast: the one cast at the same station on the cruise of the label’s month, found withmatch_by_site_datetime(), then the Niskin bottle nearest the sample’s depth withmatch_nearest_by_depth()(Q06)
Setup
devtools::load_all(here::here("../calcofi4db"))
devtools::load_all(here::here("../calcofi4r"))
librarian::shelf(
CalCOFI/calcofi4db, CalCOFI/calcofi4r,
DBI, dplyr, DT, fs, glue, here, 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"))
cc <- read_calcofi_meta(here("ingest_sio_cetacean-edna.qmd"))
provider <- cc$provider
dataset <- cc$dataset
tables_owned <- cc$tables_owned
ds_key <- as.character(glue("{provider}_{dataset}"))
dir_label <- ds_key
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_meta <- here(glue("metadata/{provider}/{dataset}"))
dir_dl <- here(glue("data/cache/{dir_label}")); dir_create(dir_dl)
publish_to_gcs <- Sys.getenv("CALCOFI_SKIP_GCS") == ""
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")Read Source Data
# CalCOFI/marmam-app at a pinned commit (see ingest_sio_cetacean-sightings.qmd)
src_sha <- "7bc0e18d7cb0ac7ba58d7bc4d47e57c50bd9bc80"
src_url <- glue("https://raw.githubusercontent.com/CalCOFI/marmam-app/{src_sha}/data/edna_data/edna.csv")
src_file <- file.path(dir_dl, "edna.csv")
downloaded <- overwrite_all || !file_exists(src_file)
if (downloaded) download.file(src_url, src_file, quiet = TRUE, mode = "wb")
src_stamp <- stamp_source_access(files = src_file) |>
mutate(source = as.character(src_url), method = if (downloaded) "download" else "file_mtime")
if (publish_to_gcs) {
sync_to_gcs(local_dir = dir_dl, gcs_prefix = glue("archive/{provider}/{dataset}"),
bucket = "calcofi-files-public")
} else {
cat("CALCOFI_SKIP_GCS set -- source NOT archived to gs://calcofi-files-public\n")
}rsync /Users/bbest/Github/CalCOFI/.worktrees/workflows-pr117/data/cache/sio_cetacean-edna -> gs://calcofi-files-public/archive/sio/cetacean-edna
Sync complete: 1 copied, 0 removed (rsync)
# A tibble: 1 × 4
file action size reason
<chr> <chr> <dbl> <chr>
1 <rsync> uploaded NA parallel rsync
d_raw <- read_csv(src_file, col_types = cols(.default = "c"))
sp_cols <- c("Gg", "Tt", "Mn", "Dc", "Lb", "Lo", "Dd")
stopifnot(
"row count changed at this commit" = nrow(d_raw) == 136L,
"the seven species columns are present" = all(sp_cols %in% names(d_raw)),
"a species cell is 'x' or blank" = all(unlist(d_raw[sp_cols]) %in% c("x", NA)))
cat(glue("edna.csv: {nrow(d_raw)} rows, {sum(unlist(d_raw[sp_cols]) %in% 'x')} detections"), "\n")edna.csv: 136 rows, 15 detections
Build Sample + Detection Tables
Three samples are listed twice, once sequenced and once PCR-negative. The sequenced row is kept (Q05): it is the one that went further through the pipeline, and two of the three carry a detection.
num <- function(x) suppressWarnings(as.numeric(x))
dupes <- d_raw |> group_by(Sample) |> filter(n() > 1) |> ungroup()
stopifnot(
"exactly three samples are listed twice" = n_distinct(dupes$Sample) == 3L && nrow(dupes) == 6L,
"each duplicate pair is one sequenced + one PCR-negative row" =
all(table(dupes$eDNA_Process) == 3L))
d_dedup <- d_raw |>
arrange(Sample, eDNA_Process != "sequenced") |>
distinct(Sample, .keep_all = TRUE)
d_sample <- d_dedup |>
transmute(
sample_label = Sample,
edna_process = eDNA_Process,
illumina_name = Illumina_name,
station_name,
season = str_trim(season),
# line/station from the NCOG sample name (YYYYMM_LLL.L_SSS.S_depth), not the
# line/station columns: on 7 rows those disagree with the name, and the
# row's own latitude/longitude sit on the name's station (asserted below)
line_src = num(line),
station_src = num(station),
line = num(substr(Sample, 8, 12)),
station = num(substr(Sample, 14, 18)),
site_key = normalize_site_key(sprintf("%.1f %.1f", line, station)),
depth_m = num(depth),
latitude = num(latitude),
longitude = num(longitude),
cruise_label = cruise,
# no sampling date in the source: the 15th of the cruise month (CCYYMM) is
# the anchor the bottle-cast match searches around (Q06)
nominal_date = make_date(2000L + as.integer(substr(cruise, 3, 4)),
as.integer(substr(cruise, 5, 6)), 15L),
# the match key: station + the cruise's designated year-month, which is
# how a cruise_key begins (YYYY-MM-NODC)
site_ym = paste(site_key, format(nominal_date, "%Y-%m")))
xy_name <- cc_calcofi_to_lonlat(d_sample$line, d_sample$station)
n_ls_fix <- sum(d_sample$line != d_sample$line_src | d_sample$station != d_sample$station_src)
stopifnot(
"every sample name is YYYYMM_LLL.L_SSS.S_depth" =
all(grepl("^\\d{6}_\\d{3}\\.\\d_\\d{3}\\.\\d_\\d+(\\.\\d+)?$", d_sample$sample_label)),
"the name's station is where the row's own position is (within 0.02 deg)" =
all(abs(xy_name$latitude - d_sample$latitude) < 0.02 &
abs(xy_name$longitude - d_sample$longitude) < 0.02),
"the name's depth is the depth column" =
all(as.numeric(sub(".*_", "", d_sample$sample_label)) == d_sample$depth_m),
"sample_label is unique" = !anyDuplicated(d_sample$sample_label),
"every sample has a station, depth and position" =
!anyNA(d_sample$site_key) && !anyNA(d_sample$depth_m) &&
!anyNA(d_sample$latitude) && !anyNA(d_sample$longitude),
"cruise_label parses to a date" = !anyNA(d_sample$nominal_date),
"the label's YYYYMM matches the sample name's" =
all(substr(d_sample$sample_label, 3, 6) == substr(d_sample$cruise_label, 3, 6)))
dbWriteTable(con, "edna_sample", d_sample, overwrite = TRUE)
d_detection <- d_dedup |>
select(sample_label = Sample, all_of(sp_cols)) |>
pivot_longer(-sample_label, names_to = "species_code", values_to = "cell") |>
transmute(sample_label, species_code, detected = as.integer(cell %in% "x"))
dbWriteTable(con, "edna_detection", d_detection, overwrite = TRUE)
cat(glue("line/station taken from the sample name; the line/station columns disagree on ",
"{n_ls_fix} rows (Q10)"), "\n")line/station taken from the sample name; the line/station columns disagree on 7 rows (Q10)
cat(glue("edna_sample: {nrow(d_sample)} samples ",
"({sum(d_sample$edna_process == 'sequenced')} sequenced); ",
"edna_detection: {nrow(d_detection)} rows, {sum(d_detection$detected)} detections"), "\n")edna_sample: 133 samples (71 sequenced); edna_detection: 931 rows, 15 detections
Resolve cruise_key, time + Spatial
NCOG eDNA is filtered from the CTD-rosette Niskin bottles, so a sample’s time and cruise are those of its bottle cast, not of the net tow at the same station. match_by_site_datetime() finds, for each sample, the calcofi_bottle cast at the same site_key on the cruise of the label’s month (key site_ym; exactly one cast per sample at this commit, asserted) nearest the 15th of that month, and match_nearest_by_depth() then finds the Niskin bottle on that cast nearest the sample’s depth (within 5 m). The sample takes the cast’s cruise_key and time. Every one of the 133 samples has its cast, including the 16 on CC1611, a Sally Ride cruise with no net tows (Q06).
The matched bottle is kept in the source table as evidence (it also confirms the 515 m and 170 m depths, Q09). It is not set as a cross-dataset parent_sample_key; that is a modelling decision still open.
bottle_parquet <- cc_stage_path("parquet", "calcofi_bottle", "sample.parquet")
dbExecute(con, glue(
"CREATE OR REPLACE TABLE bottle_cast AS
SELECT sample_key AS cast_sample_key, site_key, cruise_key, datetime,
site_key || ' ' || substr(cruise_key, 1, 7) AS site_ym
FROM read_parquet('{bottle_parquet}')
WHERE sample_type = 'cast' AND site_key IS NOT NULL
AND cruise_key IS NOT NULL AND datetime IS NOT NULL"))[1] 35595
dbExecute(con, glue(
"CREATE OR REPLACE TABLE bottle_niskin AS
SELECT sample_key AS bottle_sample_key, parent_sample_key AS cast_sample_key,
depth_max_m AS depth_m
FROM read_parquet('{bottle_parquet}')
WHERE sample_type = 'bottle' AND depth_max_m IS NOT NULL
AND parent_sample_key IN (
SELECT cast_sample_key FROM bottle_cast
WHERE site_ym IN (SELECT site_ym FROM edna_sample))"))[1] 2716
# match_by_site_datetime() takes the nearest cast by date and LIMIT 1, so a
# station occupied twice on one cruise would be a coin toss: assert there is none
n_multi_cast <- dbGetQuery(con, "
SELECT COUNT(*) FROM (
SELECT site_ym FROM bottle_cast WHERE site_ym IN (SELECT site_ym FROM edna_sample)
GROUP BY 1 HAVING COUNT(*) > 1)")[[1]]
stopifnot("one bottle cast per station x cruise month among the eDNA samples" = n_multi_cast == 0)
# 45 days either side of the 15th: a cruise can begin late in the month before
# its designated month (CC1402's cast at 90.0 37.0 is 2014-01-29)
cast_match <- match_by_site_datetime(
con, "edna_sample", "bottle_cast",
fk_col = "cast_sample_key", ref_pk = "cast_sample_key",
key_col = "site_ym", datetime_col = "nominal_date", ref_datetime_col = "datetime",
window_days = 45)match_by_site_datetime: 133/133 edna_sample rows matched to bottle_cast (100%) within 45 d
niskin_match <- match_nearest_by_depth(
con, "edna_sample", "bottle_niskin",
fk_col = "bottle_sample_key", ref_pk = "bottle_sample_key",
parent_fk = "cast_sample_key", axis_col = "depth_m", tolerance = 5)match_nearest_by_depth: 133/133 edna_sample rows matched to bottle_niskin (100%) within 5 depth_m
dbExecute(con, "ALTER TABLE edna_sample ADD COLUMN IF NOT EXISTS cruise_key VARCHAR")[1] 0
dbExecute(con, "ALTER TABLE edna_sample ADD COLUMN IF NOT EXISTS datetime_start_utc TIMESTAMP")[1] 0
dbExecute(con, "
UPDATE edna_sample e SET cruise_key = c.cruise_key, datetime_start_utc = c.datetime
FROM bottle_cast c WHERE c.cast_sample_key = e.cast_sample_key")[1] 133
# the shared crosswalk (owned by ingest_sio_cetacean-sightings.qmd) is re-validated
# here rather than trusted: each key must be the label's year-month + its ship's
# NODC code, and where an eDNA label is in it (CC1611), the bottle casts must
# place the samples on that same cruise
d_ship <- dbGetQuery(con, glue(
"SELECT ship_key, ship_nodc FROM read_parquet('{cc_stage_path('parquet','swfsc_ichthyo','ship.parquet')}')"))
d_xwalk <- read_csv(here("metadata/sio/cetacean-sightings/cruise_label_crosswalk.csv"),
col_types = cols(.default = "c")) |>
left_join(d_ship, by = "ship_key") |>
mutate(minted = paste0("20", substr(cruise_label, 3, 4), "-", substr(cruise_label, 5, 6), "-", ship_nodc))
d_label_ck <- dbGetQuery(con, "
SELECT cruise_label, COUNT(DISTINCT cruise_key) AS n_keys, ANY_VALUE(cruise_key) AS cruise_key,
COUNT(*) AS n_samples
FROM edna_sample GROUP BY 1 ORDER BY 1")
d_xw_edna <- d_label_ck |> inner_join(d_xwalk |> select(cruise_label, xwalk_key = cruise_key), by = "cruise_label")
stopifnot(
"every sample has its bottle cast (a new sample without one must be looked at)" =
cast_match$matched == cast_match$total,
"every sample has a Niskin bottle within 5 m of its depth on that cast" =
niskin_match$matched == nrow(d_sample),
"every sample has a cruise_key and a time" =
dbGetQuery(con, "SELECT COUNT(*) FROM edna_sample
WHERE cruise_key IS NULL OR datetime_start_utc IS NULL")[[1]] == 0,
"a cruise label's samples sit on one cruise_key" = all(d_label_ck$n_keys == 1),
"the cruise_key's year-month is the label's" =
all(substr(d_label_ck$cruise_key, 1, 7) ==
paste0("20", substr(d_label_ck$cruise_label, 3, 4), "-", substr(d_label_ck$cruise_label, 5, 6))),
"every crosswalk ship is in the ship reference" = !anyNA(d_xwalk$ship_nodc),
"each crosswalk cruise_key is the label's year-month + its ship's NODC code" =
all(d_xwalk$minted == d_xwalk$cruise_key),
"where the crosswalk names an eDNA label, the bottle casts agree with it" =
all(d_xw_edna$cruise_key == d_xw_edna$xwalk_key))
d_depth_gap <- dbGetQuery(con, "
SELECT e.sample_label, e.depth_m, n.depth_m AS bottle_depth_m,
ABS(e.depth_m - n.depth_m) AS depth_diff_m
FROM edna_sample e JOIN bottle_niskin n USING (bottle_sample_key)
WHERE e.depth_m <> n.depth_m ORDER BY depth_diff_m DESC")
cat(glue("bottle cast matched for {cast_match$matched}/{cast_match$total} samples; ",
"Niskin bottle within 5 m for {niskin_match$matched} ",
"({nrow(d_depth_gap)} not at the exact depth, max {max(c(0, d_depth_gap$depth_diff_m))} m); ",
"{nrow(d_label_ck)} cruise labels, {nrow(d_xw_edna)} of them in the shared crosswalk"), "\n")bottle cast matched for 133/133 samples; Niskin bottle within 5 m for 133 (12 not at the exact depth, max 4 m); 12 cruise labels, 1 of them in the shared crosswalk
d_label_ck |> datatable(caption = "Cruise label -> cruise_key, from the bottle casts", rownames = FALSE)# spatial last: nothing above has to UPDATE a table that carries geometry
load_prior_tables(con, parquet_dir = cc_stage_path("parquet", "swfsc_ichthyo"),
tables = c("grid"), geom_tables = c("grid"), as_view = TRUE)Loaded grid: 218 rows (VIEW) (GEOMETRY)
# A tibble: 1 × 3
table rows has_geom
<chr> <dbl> <lgl>
1 grid 218 TRUE
add_point_geom(con, "edna_sample", lon_col = "longitude", lat_col = "latitude")Added geom column to edna_sample table
assign_grid_key(con, "edna_sample")Assigned grid_key to edna_sample: in_grid = 133
status n
1 in_grid 133
Load Dataset Metadata + Schema
this_provider <- provider; this_dataset <- dataset
d_dataset <- ingest_yaml_to_dataset_df(read_ingest_yaml(here())) |>
filter(provider == this_provider, dataset == this_dataset)
stopifnot(nrow(d_dataset) == 1)
dbWriteTable(con, "dataset", as.data.frame(d_dataset), overwrite = TRUE)
d_meas_type <- read_measurement_type(here("metadata/measurement_type.csv"))
stopifnot("edna_presence is registered" = "edna_presence" %in% d_meas_type$measurement_type)
dbWriteTable(con, "measurement_type", as.data.frame(d_meas_type), overwrite = TRUE)
d_qual <- read_csv(here("metadata/measurement_qual.csv"), col_types = cols(.default = "c")) |>
filter(code_set == "cetacean-edna")
stopifnot("every eDNA_Process value is a registered code" =
all(d_sample$edna_process %in% d_qual$qual_code))
src_rels <- list(
primary_keys = list(edna_sample = "sample_label"),
foreign_keys = list(
list(table = "edna_detection", column = "sample_label",
ref_table = "edna_sample", ref_column = "sample_label")))
cc_erd(con, tables = c("edna_sample", "edna_detection", "dataset"), rels = src_rels,
colors = list(lightblue = c("edna_sample", "edna_detection"), white = "dataset"))
Questions for Data Providers
questions_datatable(
here(cc$questions_file),
caption = "Questions for the cetacean eDNA data providers (ranked)")Emit Core Tables
| source | core | transform |
|---|---|---|
edna_sample |
sample (water) |
every sample, sequenced or not; key sio_cetacean-edna:water:<sample_label>; depth_min_m = depth_max_m = depth_m; time and cruise of the matched bottle cast |
edna_detection.detected |
obs edna_presence |
1/0 for each of the seven species on every sequenced sample; measurement_qual = edna_process verbatim (always sequenced) |
| PCR-negative samples | – | held (Q04): a sample row, no obs; the 0s in the source are not published |
Not published (kept in the archived source): illumina_name, station_name, season, the app’s detections / SpeciesName / SubOrder convenience columns, the matched bottle (bottle_sample_key, evidence only), and the species columns of the PCR-negative samples.
Taxon vocabulary
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)
# the same codes as the visual survey; their names come from its vocabulary
d_vocab <- read_csv(here("metadata/sio/cetacean-sightings/species_codes.csv"),
col_types = cols(.default = "c")) |>
filter(species_code %in% sp_cols) |>
transmute(ds_taxa_code = species_code, ds_scientific_name = scientific_name,
ds_common_name = common_name)
stopifnot("all seven codes named" = setequal(d_vocab$ds_taxa_code, sp_cols))
n_staged <- append_dataset_taxon(con, ds_key, d_vocab)
ensure_taxon_xref(con, mt_taxon, tx_over, cache_csv = here("metadata/taxon_xref.csv"))taxon xref: 0 TSN + 2 AphiaID + 5 name requested; 0 to fetch, 7 cached
taxon xref: 0 TSN + 5 AphiaID + 0 name requested; 0 to fetch, 5 cached
_taxon_xref: 12 cross-reference rows (12 with worms_id, 6 with itis_id, 0 re-keyed)
ensure_taxon_lineage(con, mt_taxon, tx_over, cache_csv = here("metadata/taxon_lineage.csv"))taxon lineage: 7 WoRMS + 0 ITIS requested; 0 to fetch, 7 cached
taxon lineage: 0 Aves taxa keyed on ITIS, 7 taxa on WoRMS
taxon xref: 0 TSN + 27 AphiaID + 0 name requested; 0 to fetch, 27 cached
taxon: 27 lineage rows for 7 taxa (0 unresolved)
n_taxon <- build_taxon_reference(con, mt_taxon, tx_over)
n_ds_taxon <- resolve_dataset_taxon(con, mt_taxon, tx_over)taxon overrides ('sio_cetacean-edna'): 2 registry row(s) matched 2 vocabulary row(s): 2 applied, 0 skipped
n_tx_group <- build_taxon_group(con, read_taxon_group_rules(here("metadata/taxon_group.csv")))
chk <- check_dataset_taxon(con, ds_key, codes = sp_cols)check_dataset_taxon('sio_cetacean-edna'): 7 taxa, 7 keyed by an authority, 0 local (0 allowed); 0 finding(s)
dt <- dbGetQuery(con, "SELECT ds_taxa_code, taxon_key FROM dataset_taxon WHERE dataset_key = ?",
params = list(ds_key))
key_of <- function(code) dt$taxon_key[dt$ds_taxa_code == code]
stopifnot(
"every code keys a taxon" = !anyNA(dt$taxon_key) && nrow(dt) == 7L,
"Dc keys Delphinus capensis (Q08)" = identical(key_of("Dc"), "worms:383816"),
"Dc and Dd are distinct taxa (Q08)" = !identical(key_of("Dc"), key_of("Dd")),
"Lo keys Sagmatias obliquidens (Q08)" = identical(key_of("Lo"), "worms:1571909"))
cat(glue("vocabulary: {n_staged} codes; taxon {n_taxon}, dataset_taxon {n_ds_taxon}"), "\n")vocabulary: 7 codes; taxon 27, dataset_taxon 7
Sample, obs
append_sample(con, sample_arm_self(
ds_key, "edna_sample", "sample_label", "water",
site_expr = "site_key", depth_min = "depth_m", depth_max = "depth_m"))Loaded extension: spatial
# a PCR-negative sample did not amplify: its 0s are not non-detections, so it
# publishes no obs until the provider answers Q04 (Ben, 2026-10-01). Its sample
# row stays: the water was collected and screened, the result is what is held.
append_obs(con, glue("
SELECT 'bio', '{ds_key}', {ns_key(ds_key, 'water', 's.sample_label')},
s.grid_key, s.cruise_key, s.latitude, s.longitude,
CAST(s.datetime_start_utc AS TIMESTAMP), s.depth_m, s.depth_m,
dt.taxon_key, NULL::VARCHAR, 'edna_presence',
CAST(d.detected AS DOUBLE), s.edna_process, NULL::DOUBLE
FROM edna_detection d JOIN edna_sample s USING (sample_label)
JOIN dataset_taxon dt ON dt.dataset_key = '{ds_key}' AND dt.ds_taxa_code = d.species_code
WHERE s.edna_process = 'sequenced'"))Loaded extension: h3
q1 <- function(sql) dbGetQuery(con, sql)[[1]]
n_seq <- sum(d_sample$edna_process == "sequenced")
n_pcr_neg_detect <- d_detection |>
semi_join(d_sample |> filter(edna_process != "sequenced"), by = "sample_label") |>
pull(detected) |> sum()
core <- list(
sample = q1("SELECT COUNT(*) FROM sample"),
sample_sequenced = n_seq,
sample_held = nrow(d_sample) - n_seq,
obs = q1("SELECT COUNT(*) FROM obs"),
obs_present = q1("SELECT COUNT(*) FROM obs WHERE measurement_value = 1"))
cat(glue("core projection -- ", paste(names(core), unlist(core), sep = "=", collapse = " ")), "\n")core projection -- sample=133 sample_sequenced=71 sample_held=62 obs=497 obs_present=15
stopifnot(
"one sample per eDNA sample, PCR-negative ones included" = core$sample == nrow(d_sample),
"seven obs per sequenced sample" = core$obs == 7L * n_seq,
"one obs per sample x taxon (Q08)" =
q1("SELECT COUNT(*) FROM (SELECT sample_key, taxon_key FROM obs GROUP BY ALL HAVING COUNT(*) > 1)") == 0,
"the source has no detection on a PCR-negative sample" = n_pcr_neg_detect == 0,
"presences = detections in the source" = core$obs_present == sum(d_detection$detected),
"a PCR-negative sample publishes no obs (held, Q04)" =
q1("SELECT COUNT(*) FROM obs WHERE measurement_qual IS DISTINCT FROM 'sequenced'") == 0,
"every obs.sample_key resolves in sample" =
q1("SELECT COUNT(*) FROM obs o LEFT JOIN sample s USING (sample_key) WHERE s.sample_key IS NULL") == 0,
"every obs.taxon_key resolves in taxon" =
q1("SELECT COUNT(*) FROM obs o LEFT JOIN taxon t USING (taxon_key) WHERE t.taxon_key IS NULL") == 0)Declared NULLs
results <- validate_for_release(con, checks = "all", strict = FALSE)
nullable <- tribble(
~table, ~column, ~n, ~reason,
"sample", "parent_sample_key", nrow(d_sample), "single-level dataset: every sample is a root (the matched bottle is not set as a cross-dataset parent)",
"sample", "grid_key", q1("SELECT COUNT(*) FROM sample WHERE grid_key IS NULL"),
"station outside the CalCOFI grid cells")
stopifnot(
"no sample lacks a time (every one has its bottle cast)" =
q1("SELECT COUNT(*) FROM sample WHERE datetime IS NULL") == 0,
"no sample lacks a cruise_key" =
q1("SELECT COUNT(*) FROM sample WHERE cruise_key IS NULL") == 0,
"every sample is a root" =
q1("SELECT COUNT(*) FROM sample WHERE parent_sample_key IS NULL") == nrow(d_sample),
"the grid_key NULLs are counted, not assumed (a ratchet: none at this commit)" =
nullable$n[nullable$column == "grid_key"] == 0)
cat("validate_for_release:", ifelse(results$passed, "PASSED", "reported findings -- reconciled against the declared NULLs below"), "\n")validate_for_release: reported findings -- reconciled against the declared NULLs below
nullable |> datatable(caption = "Declared NULLs (count and reason)", rownames = FALSE)Measurement bounds
b <- check_measurement_bounds(con, "obs", mt = d_meas_type, dataset_key = ds_key,
depth_col = "depth_max_m")
bounds_datatable(b)stopifnot("edna_presence is declared and respected" = all(b$status == "ok"))Preview
dbGetQuery(con, "
SELECT dt.ds_taxa_code, t.scientific_name,
COUNT(*) FILTER (WHERE o.measurement_qual = 'sequenced') AS n_sequenced,
SUM(o.measurement_value) AS n_detected
FROM obs o JOIN taxon t USING (taxon_key)
JOIN dataset_taxon dt ON dt.taxon_key = o.taxon_key AND dt.dataset_key = o.dataset_key
GROUP BY ALL ORDER BY n_detected DESC") |>
datatable(caption = "Detections by species", rownames = FALSE)core_tbls <- core_output_tables(con)
cc_erd(con, tables = core_tbls, rels = core_relationships(core_tbls))
Write Outputs + Upload
dir_create(dir_parquet)
tbls_out <- core_output_tables(con, extra = c("measurement_type", "dataset"))
write_parquet_outputs(
con = con, output_dir = dir_parquet, tables = tbls_out,
sort_by = list(obs = c("grid_key", "measurement_type")),
strip_provenance = FALSE)Reused sample: 133 rows unchanged (no re-write/upload)
Reused obs: 497 rows unchanged (no re-write/upload)
Reused taxon: 27 rows unchanged (no re-write/upload)
Reused dataset_taxon: 7 rows unchanged (no re-write/upload)
Reused taxon_group: 7 rows unchanged (no re-write/upload)
Reused measurement_type: 220 rows unchanged (no re-write/upload)
Reused dataset: 1 rows unchanged (no re-write/upload)
Wrote manifest to /Users/bbest/Github/CalCOFI/.worktrees/workflows-pr117/data/parquet/sio_cetacean-edna/manifest.json
# A tibble: 7 × 5
table rows file_size path partitioned
<chr> <dbl> <dbl> <chr> <lgl>
1 sample 133 7604 /Users/bbest/_big/calcofi/parque… FALSE
2 obs 497 8975 /Users/bbest/_big/calcofi/parque… FALSE
3 taxon 27 5008 /Users/bbest/_big/calcofi/parque… FALSE
4 dataset_taxon 7 1727 /Users/bbest/_big/calcofi/parque… FALSE
5 taxon_group 7 1058 /Users/bbest/_big/calcofi/parque… FALSE
6 measurement_type 220 16838 /Users/bbest/_big/calcofi/parque… FALSE
7 dataset 1 5072 /Users/bbest/_big/calcofi/parque… FALSE
build_relationships_json(
rels = core_relationships(tbls_out), output_dir = dir_parquet,
provider = provider, dataset = dataset)Wrote relationships.json: 7 PKs, 8 FKs
[1] "/Users/bbest/Github/CalCOFI/.worktrees/workflows-pr117/data/parquet/sio_cetacean-edna/relationships.json"
d_tbls_rd <- read_csv(file.path(dir_meta, "tbls_redefine.csv"))
d_flds_rd <- read_csv(file.path(dir_meta, "flds_redefine.csv"))
build_metadata_json(
con = con, d_tbls_rd = d_tbls_rd, d_flds_rd = d_flds_rd,
metadata_derived_csv = c(here("metadata/core_dictionary.csv"),
file.path(dir_meta, "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,
sources = src_stamp)Set DuckDB COMMENT ON for tables and columns
Wrote metadata.json: 7 tables, 106 columns
metadata.json documentation gaps (7 tables, 106 columns) — these render blank in cc_describe_table() / cc_db_catalog():
tables with no description_md: 2 measurement_type, dataset
columns with no description_md: 43 dataset.provider, dataset.dataset, dataset.dataset_name, dataset.dataset_name_short, dataset.category, dataset.color, dataset.description, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others (+31 more)
measurement columns with no units: 34 dataset.dataset_name_short, dataset.category, dataset.color, dataset.citation_main, dataset.citation_others, dataset.link_calcofi_org, dataset.link_data_source, dataset.link_others, dataset.tables, dataset.coverage_temporal, dataset.coverage_spatial, dataset.license (+22 more)
backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run
[1] "/Users/bbest/Github/CalCOFI/.worktrees/workflows-pr117/data/parquet/sio_cetacean-edna/metadata.json"
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("CALCOFI_SKIP_GCS set -- parquet kept local, NOT synced to gs://calcofi-db\n")
}rsync /Users/bbest/_big/calcofi/parquet/sio_cetacean-edna -> gs://calcofi-db/ingest/sio_cetacean-edna
Copied 3 sidecar(s) from /Users/bbest/Github/CalCOFI/.worktrees/workflows-pr117/data/parquet/sio_cetacean-edna
Sync complete: 10 copied, 0 removed (rsync)
# A tibble: 10 × 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
close_duckdb(con)
cat(glue("Parquet outputs written to: {dir_parquet}"), "\n")Parquet outputs written to: /Users/bbest/Github/CalCOFI/.worktrees/workflows-pr117/data/parquet/sio_cetacean-edna