Ingest CalCOFI CTD Cast Data

Published

2026-08-14

1 Overview

This notebook ingests CalCOFI CTD data from https://calcofi.org/data/oceanographic-data/ctd-cast-files/, downloads and unzips final and preliminary CTD files, normalizes into tidy tables (ctd_cast, ctd_measurement, ctd_thin, ctd_summary), and exports to Parquet for the CalCOFI integrated database.

1.1 Key Features

  1. Web Scraping: Scrapes all CTD .zip download links from calcofi.org

  2. Smart Filtering:

    • Downloads all .zip files for archival completeness
    • Only unzips final and preliminary files
    • Skips raw/cast/test files
  3. Priority-based Selection:

    • For each cruise, selects final if available, otherwise preliminary
    • Excludes raw/test/prodo cast files
  4. Tidy Normalization:

    • ctd_cast: one row per unique cast (cruise/station/direction)
    • ctd_measurement: long-format sensor readings at each depth (supplemental)
    • ctd_thin: adaptively-thinned ctd_measurement — single direction, canonical types, ~10 m grid with inflections preserved; the headline CTD table
    • ctd_summary: summary stats per station/depth/measurement_type across cast directions
    • measurement_type: reference table for measurement codes
  5. Standardized Workflow: follows patterns from ingest_calcofi_bottle.qmd — deterministic UUIDs, GCS parquet uploads, metadata sidecars, calcofi4db utilities

  6. Findings: the report at the end — what is known to be wrong with this data, measured from the parquet that was just written and checked by the same QC rule registry apps/ctd-qaqc runs. It renders even when the heavy path is skipped (see Check for Resumable State), so it can be revised without an hour of recomputation.

2 Setup

Code
# chunk timing hook — prints elapsed time for each chunk
knitr::knit_hooks$set(time_it = function(before, options) {
  if (before) {
    .time_it_t0 <<- Sys.time()
  } else {
    elapsed <- round(difftime(Sys.time(), .time_it_t0, units = "secs"), 1)
    tnow <- format(Sys.time(), "%H:%M:%S")
    message(glue::glue("R chunk {options$label}: {elapsed}s ~ {tnow}"))
  }
})
knitr::opts_chunk$set(time_it = TRUE)

devtools::load_all(here::here("../calcofi4db"))
devtools::load_all(here::here("../calcofi4r"))
librarian::shelf(
  CalCOFI / calcofi4db,
  CalCOFI / calcofi4r,
  DBI,
  dplyr,
  DT,
  fs,
  glue,
  here,
  httr,
  janitor,
  lubridate,
  mapview,
  plotly,
  purrr,
  ps,
  readr,
  rvest,
  sf,
  stringr,
  tibble,
  tidyr,
  zip,
  quiet = T
)

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

# say(): status output to STDOUT so it renders in the notebook.
#
# This notebook previously used message() for ~37 status lines. message() writes
# to stderr, which Quarto does not reliably surface under `code-fold: true` — so
# most of the diagnostics below were being computed and then never shown. The
# repo convention is cat() (see .claude/skills/ingest-new/SKILL.md), but cat() adds no
# trailing newline, so this thin wrapper keeps message()'s line semantics.
say <- function(...) cat(..., "\n", sep = "")

# provider/dataset/metadata read from this file's authoritative YAML block
cc           <- read_calcofi_meta(here("ingest_calcofi_ctd-cast.qmd"))
provider     <- cc$provider
dataset      <- cc$dataset
dataset_name <- cc$dataset_meta$dataset_name
tables_owned <- cc$tables_owned
dir_label <- glue("{provider}_{dataset}")
dir_dl <- path_expand(glue("{dir_data}/{provider}/{dataset}/download"))
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"))
db_checkpoint <- here(glue("data/wrangling/{dir_label}_checkpoint.duckdb"))
dir_tmp <- here(glue("data/tmp/{dir_label}"))
url <- "https://calcofi.org/data/oceanographic-data/ctd-cast-files/"
dir_meta <- here(glue("metadata/{provider}/{dataset}"))

dir_create(c(dir_dl, dirname(db_path), dir_parquet, dir_stage, dir_tmp), recurse = TRUE)

# NOTE: nothing destructive happens here, and no connection is opened. Both wait
# for the resume decision below, which needs the scraped zip list to fingerprint
# the inputs — deleting the wrangling DB before knowing whether the heavy path
# will run would throw away the one table the Gantt appendix reads.

# source archival handled by sync_gd_to_gcs.qmd (rclone)
# the ctd-cast download/ dir is 62GB+ — too large for inline
# sync_to_gcs() through the GD FUSE mount

# load metadata
# read_measurement_type(), NOT read_csv(): the registry stores empty cells that a
# default read turns back into the literal string "NA", which then survives the
# na.omit() in the measurement-column registry below and becomes a phantom
# quality-flag column name. It also validates the file on the way in, so a
# corrupted write fails here rather than reaching the release. See CLAUDE.md.
d_meas_type <- calcofi4db::read_measurement_type(
  here("metadata/measurement_type.csv")
)
d_flds_rd <- read_csv(glue("{dir_meta}/flds_redefine.csv"), show_col_types = F)
d_tbls_rd <- read_csv(glue("{dir_meta}/tbls_redefine.csv"), show_col_types = F)
d_cruise_corrections <- read_csv(
  glue("{dir_meta}/cruise_key_corrections.csv"), show_col_types = F)

# the `data_stage` vocabulary, MOST ADVANCED FIRST — this ordering IS the
# supersession rule (Final 1m-Binned supersedes Preliminary CTD & Bottle 1m-Binned
# supersedes Preliminary CTD 1m-Binned), so `match()` against it gives the priority
# and no second list can drift out of step with it. Declared here because five
# places downstream need it: the classifier, the per-cruise precedence filter, the
# emit guard, the findings table and the coverage Gantt.
CTD_DATA_STAGES <- c(
  "final", "preliminary_with_bottle", "preliminary_without_bottle")
CTD_STAGE_LABELS <- c(
  final                      = "Final 1m-Binned",
  preliminary_with_bottle    = "Preliminary CTD & Bottle 1m-Binned",
  preliminary_without_bottle = "Preliminary CTD 1m-Binned")

3 Prime Downloads from the Object Store

The authoritative source for the CTD .zip files is the organization Shared Drive (“CalCOFI Data Folder”), mirrored to gs://calcofi-files-public/_sync/calcofi/ctd-cast/download/ by scripts/sync_gdrive_to_gcs.sh (per-cruise CTDCast/CTDPrelim drops) and scripts/sync_jrw_ctd_to_gcs.sh (Jim Wilkinson’s _CTDFinalDB.zip final archive, 1993-08 onward). Copy anything new into dir_dl so the inventory below can see it.

This must run before the inventory, not after. It used to sit after both the scrape and the resume decision, which made a Drive-only zip structurally invisible: d_zips was built purely from calcofi.org, so nothing ever unzipped it and the fingerprint never noticed it. 20-2607SH_CTDPrelim.zip sat on GCS and on disk for over a week that way. Set CTD_ZIP_SOURCE="" to disable, or override it to point elsewhere.

Code
# authoritative zip source (gdrive→gcs); empty string disables priming
ctd_zip_source <- Sys.getenv(
  "CTD_ZIP_SOURCE",
  "gcs-calcofi:calcofi-files-public/_sync/calcofi/ctd-cast/download")

prime_zips_from_gcs <- function(src, dest_dir) {
  # skip if disabled or rclone unavailable
  if (src == "" || Sys.which("rclone") == "")
    return(invisible(0L))
  # system2() does NOT quote its args — it pastes them into a shell command — and
  # dest_dir is under "~/My Drive/…", so an unquoted path split on the space and
  # rclone saw three positional arguments instead of two:
  #   Command copy needs 2 arguments maximum: you provided 3 non flag arguments:
  #   ["gcs-calcofi:…/download" "/Users/bbest/My" "Drive/projects/…/download"]
  # It then failed SILENTLY, because the exit status was never checked: the run
  # printed "Priming N zip(s)", primed nothing, and fell through to scraping
  # calcofi.org. Harmless where the zips are already local; a slow surprise on a
  # fresh machine.
  n_src <- tryCatch(
    length(system2(
      "rclone", c("lsf", shQuote(src), "--include", shQuote("*.zip"),
                  "--max-depth", "1"),
      stdout = TRUE, stderr = FALSE)),
    error = function(e) 0L)
  if (n_src == 0) {
    say(glue("No zips at {src} — skipping GCS prime (will use calcofi.org)"))
    return(invisible(0L))
  }
  say(glue("Priming {n_src} zip(s) from {src} → {dest_dir}"))
  status <- system2("rclone", c(
    "copy", shQuote(src), shQuote(dest_dir),
    "--include", shQuote("*.zip"), "--max-depth", "1",
    "--transfers", "8", "--checkers", "16"))
  if (!identical(status, 0L)) {
    say(glue("GCS prime FAILED (rclone exit {status}) — falling back to ",
             "scraping calcofi.org. This is slower but not fatal."))
    return(invisible(0L))
  }
  invisible(n_src)
}

prime_zips_from_gcs(ctd_zip_source, dir_dl)
Priming 246 zip(s) from gcs-calcofi:calcofi-files-public/_sync/calcofi/ctd-cast/download → /Users/bbest/My Drive/projects/calcofi/data-public/calcofi/ctd-cast/download

4 Build the Zip Inventory

The inventory is the union of two sources, not the web scrape alone:

  • web — the .zip links published on calcofi.org. One HTTP GET, falling back to the cached list when the site is unreachable.
  • gcs — zips that reached dir_dl from the Shared Drive but are not on calcofi.org. Two kinds today: a preliminary drop that has not been published yet (20-2607SH_CTDPrelim.zip), and Jim Wilkinson’s _CTDFinalDB.zip final archive, which is not on calcofi.org at all.

This runs on every render, ahead of the resume decision below, because the zip set is one of the two things that decide whether there is anything to recompute.

Code
# a zip's cruise and processing kind are both encoded in its filename, identically
# whichever source it came from. `19-9308NH_CTDFinalDB.zip` → cruise_key 9308NH,
# zip_type final; `20-2607SH_CTDPrelim.zip` → 2607SH, preliminary. Declared once so
# the two sources cannot drift into classifying the same name differently.
add_zip_meta <- function(d) {
  d |>
    mutate(
      cruise_key = str_extract(
        file_zip, "\\d{2}-(\\d{4}[A-Z0-9]{2,4})", group = 1),
      zip_type = case_when(
        str_detect(file_zip, "CTDFinal")         ~ "final",
        str_detect(file_zip, "CTDPrelim")        ~ "preliminary",
        str_detect(file_zip, "CTDCast|CTD_Cast") ~ "raw",
        str_detect(file_zip, "CTDTest")          ~ "test",
        TRUE                                     ~ "unknown"))
}

# --- source 1: calcofi.org ----------------------------------------------------
# read web page, extract all .zip download links; cache to CSV
cache_csv <- file.path(dir_meta, "ctd_zip_urls.csv")

d_zips_web <- tryCatch(
  {
    d <- read_html(url) |>
      html_nodes("a[href$='.zip']") |>
      html_attr("href") |>
      tibble(url = _) |>
      mutate(
        url = if_else(
          str_starts(url, "http"),
          url,
          paste0("https://calcofi.org", url)
        ),
        file_zip = basename(url),
        # year comes from the URL directory (/downloads/2026/), which is
        # authoritative for a published zip; month from the cruise code
        year = str_extract(url, "/(\\d{4})/", group = 1) |> as.integer(),
        month = str_extract(url, "-\\d{2}(\\d{2})", group = 1) |> as.integer()
      ) |>
      add_zip_meta()
    write_csv(d, cache_csv)
    say(glue("Scraped {nrow(d)} zip URLs from {url}"))
    d
  },
  error = function(e) {
    if (file_exists(cache_csv)) {
      say(glue("Website unavailable, using cached URLs from {cache_csv}"))
      read_csv(cache_csv, show_col_types = FALSE)
    } else {
      stop(e)
    }
  }
) |>
  mutate(source = "web")
Scraped 272 zip URLs from https://calcofi.org/data/oceanographic-data/ctd-cast-files/
Code
# --- source 2: zips on disk that calcofi.org does not publish ------------------
# `url` is deliberately NA: there is nothing to download, the file is already in
# dir_dl (put there by prime_zips_from_gcs above, or by hand). download_and_unzip()
# reads that NA as "already local, unzip only".
#
# year/month are parsed from the FILENAME here rather than a URL path. The two
# agree wherever both exist (20-2601RL → /downloads/2026/, 2026-01), but a local
# zip has no URL to read them from. Web rows keep the URL derivation so no
# currently-published value changes.
d_zips_local <- tibble(
  file_zip = list.files(dir_dl, pattern = "\\.zip$")) |>
  anti_join(d_zips_web, by = "file_zip") |>
  add_zip_meta() |>
  mutate(
    url    = NA_character_,
    # "19-" / "20-" gives the century, "YYMM" the rest
    year   = as.integer(str_extract(file_zip, "^(\\d{2})", group = 1)) * 100L +
             as.integer(str_extract(file_zip, "^\\d{2}-(\\d{2})", group = 1)),
    month  = as.integer(str_extract(file_zip, "^\\d{2}-\\d{2}(\\d{2})", group = 1)),
    source = "gcs") |>
  select(url, file_zip, year, month, cruise_key, zip_type, source)

d_zips <- bind_rows(d_zips_web, d_zips_local) |>
  arrange(desc(year), desc(month), file_zip)

stopifnot(!any(d_zips$zip_type == "unknown"))

say(glue("Zip inventory: {nrow(d_zips)} ",
         "({sum(d_zips$source == 'web')} calcofi.org, ",
         "{sum(d_zips$source == 'gcs')} Shared Drive only)"))
Zip inventory: 383 (272 calcofi.org, 111 Shared Drive only)
Code
d_zips |>
  mutate(
    file_zip = if_else(
      is.na(url), file_zip, glue("<a href={url}>{file_zip}</a>"))
  ) |>
  select(-url) |>
  dt(
    caption = "All zip files in the inventory",
    fname = "ctd_zip_files",
    escape = F
  )

5 Check for Resumable State

The heavy path here — download, parse, pivot — is about an hour, and this notebook is re-rendered mostly for reasons that have nothing to do with its inputs: a narrative edit, a new diagnostic, a corrected sentence. Paying an hour to prove that nothing changed is what stops a pipeline notebook being usable as a living document, so the decision is made on evidence rather than on a flag: skip the heavy path when the parquet outputs are complete and the inputs they were built from still fingerprint the same.

The inputs are the published zip URL list scraped above, plus the metadata registries that steer the pivot. calcofi4db::input_fingerprint() hashes them and records the result beside the wrangling database; a missing input hashes as <missing> rather than being skipped, so deleting a corrections file invalidates the outputs instead of reading as “no change”.

When the heavy path is skipped, every chunk from here to the GCS upload is displayed but not evaluated — their code is still the record of how the tables were built, but the tables themselves are not rebuilt, so those sections show no output. The Findings section at the end reads the parquet rather than the wrangling database and therefore always runs; that is what makes this worth doing, and what keeps a skipped render honest rather than merely quiet.

Code
# `overwrite` in libs/ingest.R is TRUE for every ingest and cannot be flipped
# globally — the comment there records that trying it broke a different notebook,
# because the skip path is not uniformly safe. So this notebook decides for itself.
# CTD_FORCE_REBUILD=TRUE (or overwrite_all) runs the heavy path regardless.
fingerprint_path <- here(glue("data/wrangling/{dir_label}_inputs.json"))

# kept OUT of dir_parquet on purpose: sync_to_gcs() mirrors that directory to a
# world-readable bucket, and this is local state about one machine's outputs.
fp <- input_fingerprint(
  # ONLY inputs that can change the OUTPUTS belong here. measurement_qual.csv is
  # deliberately absent: it is a vocabulary the diagnostics decode against, never
  # something the pivot reads, so hashing it would force an hour of rebuild for a
  # documentation edit that cannot alter a single row.
  files = c(
    here("metadata/measurement_type.csv"),
    here("metadata/core_dictionary.csv"),
    file.path(dir_meta, "flds_redefine.csv"),
    file.path(dir_meta, "tbls_redefine.csv"),
    file.path(dir_meta, "cruise_key_corrections.csv"),
    file.path(dir_meta, "metadata_derived.csv")),
  # the engine version is an input too: a change to calcofi4db's projection or
  # parsing would otherwise leave the fingerprint identical and keep outputs that
  # the current code would no longer produce
  #
  # keyed on source+filename, NOT on `url`: a Shared-Drive-only zip has no URL, so
  # hashing `d_zips$url` would let the whole Wilkinson archive (and 2607SH) arrive
  # without invalidating anything — the run would report "inputs unchanged" and
  # skip the rebuild that was the entire point.
  values = c(sort(paste(d_zips$source, d_zips$file_zip)),
             paste0("calcofi4db ", as.character(packageVersion("calcofi4db")))))
fp_prior <- read_input_fingerprint(fingerprint_path)
fp_same  <- !is.null(fp_prior) && identical(fp_prior$hash, fp$hash)

# parquet is "complete" when every non-supplemental manifest table is on disk
parquet_ok    <- FALSE
manifest_path <- file.path(dir_parquet, "manifest.json")
if (file_exists(manifest_path)) {
  mf       <- jsonlite::read_json(manifest_path)
  expected <- setdiff(mf$tables, unlist(mf$supplemental))
  parquet_ok <- all(vapply(expected, function(tbl) {
    file_exists(file.path(dir_stage, paste0(tbl, ".parquet"))) ||
      dir_exists(file.path(dir_stage, tbl))
  }, logical(1)))
}

force_rebuild <- overwrite_all ||
  isTRUE(as.logical(Sys.getenv("CTD_FORCE_REBUILD", "FALSE")))
parquet_complete <- parquet_ok && fp_same && !force_rebuild

if (parquet_complete) {
  say(glue(
    "Inputs unchanged and parquet complete ({length(mf$tables)} tables, ",
    "{format(mf$total_rows, big.mark = ',')} rows) — ",
    "skipping download/parse/pivot"))
  say(glue("  fingerprint {substr(fp$hash, 1, 12)}, recorded {fp_prior$recorded_at}"))
} else if (force_rebuild) {
  say("Rebuilding: forced (CTD_FORCE_REBUILD / overwrite_all)")
} else if (parquet_ok && !fp_same) {
  say("Rebuilding: inputs changed since the recorded fingerprint —")
  for (f in changed_inputs(fp, fp_prior)) say(glue("  {f}"))
} else {
  say("Rebuilding: no complete parquet output found")
}
Rebuilding: inputs changed since the recorded fingerprint —
  /Users/bbest/Github/CalCOFI/workflows/metadata/measurement_type.csv
  <values>
Code
# --- only now touch anything -------------------------------------------------
# overwrite keeps the checkpoint DB, the downloads, and (importantly) dir_parquet,
# so write_parquet_outputs() can content-hash dedup against the prior run and only
# changed partitions are re-written and re-uploaded.
if (overwrite && !parquet_complete) {
  if (file_exists(db_path)) file_delete(db_path)
  # clear any stale WAL/tmp from an interrupted run so the restored checkpoint
  # DB opens cleanly (avoids "WAL checkpoint iteration does not match" errors)
  db_wal <- paste0(db_path, ".wal")
  db_tmp <- paste0(db_path, ".tmp")
  if (file_exists(db_wal)) file_delete(db_wal)
  if (dir_exists(db_tmp))  dir_delete(db_tmp)
  if (overwrite_all) {
    if (dir_exists(dir_parquet))    dir_delete(dir_parquet)
    if (dir_exists(dir_stage))      dir_delete(dir_stage)
    if (file_exists(db_checkpoint)) file_delete(db_checkpoint)
    rds_files <- list.files(dir_tmp, pattern = "\\.rds$", full.names = TRUE)
    if (length(rds_files) > 0) file_delete(rds_files)
    say("Deleted parquet, checkpoint DB, and RDS intermediates")
  }
}

# restore from checkpoint DB if available (skips expensive read+bind+filter)
if (!parquet_complete && file_exists(db_checkpoint) && !file_exists(db_path)) {
  file_copy(db_checkpoint, db_path, overwrite = TRUE)
  say(glue("Restored from checkpoint: {db_checkpoint}"))
}

con <- get_duckdb_con(db_path)
load_duckdb_extension(con, "spatial")
load_duckdb_extension(con, "icu")

# limit memory to half of system RAM so DuckDB spills to disk instead of consuming all RAM
mem_gb <- ps::ps_system_memory()$total / 1024^3 / 2 |> floor()
dbExecute(con, glue("SET memory_limit = '{mem_gb}GB'"))
[1] 0
Code
dbExecute(con, glue("SET temp_directory = '{dir_tmp}'"))
[1] 0
Code
# a second, cheaper resume: ctd_raw already pivoted from a prior interrupted run
has_ctd_raw <- FALSE
if (!parquet_complete) {
  has_ctd_raw <- "ctd_raw" %in% DBI::dbListTables(con)
  if (has_ctd_raw) {
    n_raw <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_raw")$n
    say(glue(
      "Checkpoint: ctd_raw already loaded ",
      "({format(n_raw, big.mark = ',')} rows) — ",
      "skipping read+bind+filter"))
  }
}

# set eval for read+bind+filter chunks
skip_read_bind <- parquet_complete || has_ctd_raw
knitr::opts_chunk$set(eval = !skip_read_bind)

6 Choose Which Archives to Read

Jim Wilkinson’s archive and calcofi.org overlap on 66 cruises, and 130 of the 140 comparable files are identical20-0302JD_CTDBTL_001-100D.csv from 20-0302JD_CTDFinalDB.zip is md5-identical to calcofi.org’s copy inside 20-0302JD_CTDFinalQC.zip, and so are the great majority of the rest. But 8 files across 4 cruises are not (1810SR, 1501NH, 1604SH, 1607OS), so this is a preference, not an equivalence.

calcofi.org wins where both publish a final. It is the citable published source, it is what the existing 66 cruises in the release were already built from, and switching them would rewrite years of released data on the strength of an undocumented difference. The Shared Drive archive is read only for the cruises calcofi.org has no final for — which is also what keeps the ~1 hr heavy path from doubling to parse duplicates.

The archive is still mirrored completely to GCS (0.89 GB): it is the basis for any future backfill of calcofi.org, and for answering the question below.

ImportantWhere the two sources disagree, and why it is a provider question

The jrw_overlap_report chunk below compares every overlapping archive and lists the disagreements rather than asserting they cannot happen — an earlier draft asserted byte-identity and was wrong.

The clearest case is 1810SR, where the Shared Drive copy is 2.1× the size: same 74 casts and the same 82 columns, but 59,726 rows against 29,863, and its timestamps carry seconds (14-Oct-2018 18:00:17) where calcofi.org’s are truncated to the minute (10/14/2018 18:00). On that evidence the Shared Drive copy looks like the fuller product, not a stale one.

That matters mostly for obs_ctd_fullctd_thin reduces to a ~10 m grid either way — but datetime_start_utc is part of the de-duplication key, so it is not purely cosmetic. Which copy is authoritative for 2003–2019 is the provider’s call, not this notebook’s: see question calcofi_ctd-cast_20. Until it is answered the release keeps what it already had.

Code
# a GCS-only final is redundant when calcofi.org already publishes a final for that
# cruise. Everything else — every preliminary, every raw, and every final for a
# cruise calcofi.org has no final for — is read.
cruises_web_final <- d_zips |>
  filter(source == "web", zip_type == "final") |>
  pull(cruise_key) |>
  unique()

d_zips_skip <- d_zips |>
  filter(source == "gcs", zip_type == "final", cruise_key %in% cruises_web_final)

d_zips_read <- d_zips |>
  anti_join(d_zips_skip, by = "file_zip")

d_zips_new_final <- d_zips_read |>
  filter(source == "gcs", zip_type == "final")

say(glue(
  "Archives to read: {nrow(d_zips_read)} of {nrow(d_zips)} ",
  "({nrow(d_zips_skip)} GCS finals skipped as duplicates of calcofi.org; ",
  "{nrow(d_zips_new_final)} GCS finals are new coverage)"))
Archives to read: 293 of 383 (90 GCS finals skipped as duplicates of calcofi.org; 21 GCS finals are new coverage)
Code
d_zips_skip |>
  select(file_zip, cruise_key, year, month) |>
  arrange(year, month) |>
  dt(
    caption = glue(
      "Skipped: {nrow(d_zips_skip)} Shared-Drive final archives whose cruise ",
      "calcofi.org already publishes a final for (byte-identical; mirrored to ",
      "GCS but not parsed)"),
    fname = "ctd_zips_skipped"
  )

7 Download and Unzip Files

Code
# `url` is NA for a Shared-Drive-only zip: there is nothing to fetch, the file is
# already in dest_dir (prime_zips_from_gcs put it there). file_zip is passed
# explicitly rather than derived as basename(url), which would be NA for those.
download_and_unzip <- function(url, file_zip, dest_dir, zip_type, unzip = TRUE) {
  dest_file <- file.path(dest_dir, file_zip)
  dir_unzip <- file.path(dest_dir, str_remove(file_zip, "\\.zip$"))

  if (file_exists(dest_file)) {
    say(glue("Already exists: {file_zip}"))
  } else if (is.na(url)) {
    # primed source vanished between the prime and here — do not silently proceed
    # with a missing archive
    warning(glue("Expected primed zip is missing: {file_zip}"))
    return(invisible(NULL))
  } else {
    say(glue("Downloading: {file_zip}"))
    tryCatch(
      download.file(url, dest_file, mode = "wb"),
      error = function(e) {
        warning(glue("Failed to download {file_zip}: {e$message}"))
        if (file_exists(dest_file)) {
          file_delete(dest_file)
        }
        return(invisible(NULL))
      }
    )
    if (!file_exists(dest_file)) return(invisible(NULL))
  }

  if (unzip && dir_exists(dir_unzip)) {
    say(glue("Already unzipped: {file_zip}"))
  } else {
    if (zip_type %in% c("final", "preliminary")) {
      say(glue("Unzipping: {file_zip}"))
      dir_create(dir_unzip, recurse = TRUE)
      unzip(dest_file, exdir = dir_unzip)
    } else if (unzip) {
      say(glue("Skipping unzip (not final/preliminary): {file_zip}"))
    }
  }
}

d_zips_read |>
  pwalk(function(url, file_zip, zip_type, ...) {
    download_and_unzip(url, file_zip, dir_dl, zip_type, unzip = TRUE)
  })
Already exists: 20-2607SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2607SH_CTDCast.zip
Already exists: 20-2607SH_CTDPrelim.zip
Already unzipped: 20-2607SH_CTDPrelim.zip
Downloading: 20-2604SH_CTDCast.zip
Already exists: 20-2604SH_CTDPrelim.zip
Already unzipped: 20-2604SH_CTDPrelim.zip
Already exists: 20-2601RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2601RL_CTDCast.zip
Already exists: 20-2601RL_CTDPrelim.zip
Already unzipped: 20-2601RL_CTDPrelim.zip
Already exists: 20-2511SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2511SR_CTDCast.zip
Already exists: 20-2511SR_CTDPrelim.zip
Already unzipped: 20-2511SR_CTDPrelim.zip
Already exists: 20-2507SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2507SR_CTDCast.zip
Already exists: 20-2507SR_CTDPrelim.zip
Already unzipped: 20-2507SR_CTDPrelim.zip
Already exists: 20-2504SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2504SH_CTDCast.zip
Already exists: 20-2504SH_CTDPrelim.zip
Already unzipped: 20-2504SH_CTDPrelim.zip
Already exists: 20-2502RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2502RL_CTDCast.zip
Already exists: 20-2502RL_CTDPrelim.zip
Already unzipped: 20-2502RL_CTDPrelim.zip
Already exists: 20-2501RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2501RL_CTDCast.zip
Already exists: 20-2501RL_CTDPrelim.zip
Already unzipped: 20-2501RL_CTDPrelim.zip
Already exists: 20-2411SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2411SR_CTDCast.zip
Already exists: 20-2411SR_CTDPrelim.zip
Already unzipped: 20-2411SR_CTDPrelim.zip
Already exists: 20-2408SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2408SR_CTDCast.zip
Already exists: 20-2408SR_CTDPrelim.zip
Already unzipped: 20-2408SR_CTDPrelim.zip
Already exists: 20-2404SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2404SH_CTDCast.zip
Already exists: 20-2404SH_CTDPrelim.zip
Already unzipped: 20-2404SH_CTDPrelim.zip
Already exists: 20-2401RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2401RL_CTDCast.zip
Already exists: 20-2401RL_CTDPrelim.zip
Already unzipped: 20-2401RL_CTDPrelim.zip
Already exists: 20-2311SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2311SR_CTDCast.zip
Already exists: 20-2311SR_CTDPrelim.zip
Already unzipped: 20-2311SR_CTDPrelim.zip
Already exists: 20-2307SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2307SR_CTDCast.zip
Already exists: 20-2307SR_CTDPrelim.zip
Already unzipped: 20-2307SR_CTDPrelim.zip
Already exists: 20-2304SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2304SH_CTDCast.zip
Already exists: 20-2304SH_CTDPrelim.zip
Already unzipped: 20-2304SH_CTDPrelim.zip
Already exists: 20-2301RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2301RL_CTDCast.zip
Already exists: 20-2301RL_CTDPrelim.zip
Already unzipped: 20-2301RL_CTDPrelim.zip
Already exists: 20-2211SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2211SR_CTDCast.zip
Already exists: 20-2211SR_CTDPrelim.zip
Already unzipped: 20-2211SR_CTDPrelim.zip
Already exists: 20-2208BH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2208BH_CTDCast.zip
Already exists: 20-2208BH_CTDPrelim.zip
Already unzipped: 20-2208BH_CTDPrelim.zip
Already exists: 20-2204SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2204SH_CTDCast.zip
Already exists: 20-2204SH_CTDPrelim.zip
Already unzipped: 20-2204SH_CTDPrelim.zip
Already exists: 20-2111SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2111SR_CTDCast.zip
Already exists: 20-2111SR_CTDPrelim.zip
Already unzipped: 20-2111SR_CTDPrelim.zip
Already exists: 20-2107SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2107SR_CTDCast.zip
Already exists: 20-2107SR_CTDPrelim.zip
Already unzipped: 20-2107SR_CTDPrelim.zip
Already exists: 20-2105SH_CTDCast.zip
Already unzipped: 20-2105SH_CTDCast.zip
Already exists: 20-2105SH_CTDFinalQC.zip
Already unzipped: 20-2105SH_CTDFinalQC.zip
Already exists: 20-2101RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2101RL_CTDCast.zip
Already exists: 20-2101RL_CTDFinalQC.zip
Already unzipped: 20-2101RL_CTDFinalQC.zip
Already exists: 20-2010SR_CTDFinalQC.zip
Already unzipped: 20-2010SR_CTDFinalQC.zip
Already exists: 20-2010SR_CTD_Cast.zip
Skipping unzip (not final/preliminary): 20-2010SR_CTD_Cast.zip
Already exists: 20-2007SR_CTDFinalQC.zip
Already unzipped: 20-2007SR_CTDFinalQC.zip
Already exists: 20-2007SR_CTD_Cast.zip
Skipping unzip (not final/preliminary): 20-2007SR_CTD_Cast.zip
Already exists: 20-2001RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-2001RL_CTDCast.zip
Already exists: 20-2001RL_CTDFinalQC.zip
Already unzipped: 20-2001RL_CTDFinalQC.zip
Already exists: 20-1911OC_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1911OC_CTDCast.zip
Already exists: 20-1911OC_CTDFinalQC.zip
Already unzipped: 20-1911OC_CTDFinalQC.zip
Already exists: 20-1907BH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1907BH_CTDCast.zip
Already exists: 20-1907BH_CTDFinalQC.zip
Already unzipped: 20-1907BH_CTDFinalQC.zip
Already exists: 20-1904RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1904RL_CTDCast.zip
Already exists: 20-1904RL_CTDFinalQC.zip
Already unzipped: 20-1904RL_CTDFinalQC.zip
Already exists: 20-1902RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1902RL_CTDCast.zip
Already exists: 20-1902RL_CTDFinalQC.zip
Already unzipped: 20-1902RL_CTDFinalQC.zip
Already exists: 20-1810SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1810SR_CTDCast.zip
Already exists: 20-1810SR_CTDFinalQC.zip
Already unzipped: 20-1810SR_CTDFinalQC.zip
Already exists: 20-1806SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1806SR_CTDCast.zip
Already exists: 20-1806SR_CTDFinalQC.zip
Already unzipped: 20-1806SR_CTDFinalQC.zip
Already exists: 20-1804SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1804SH_CTDCast.zip
Already exists: 20-1804SH_CTDFinalQC.zip
Already unzipped: 20-1804SH_CTDFinalQC.zip
Already exists: 20-1802SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1802SH_CTDCast.zip
Already exists: 20-1802SH_CTDFinalQC.zip
Already unzipped: 20-1802SH_CTDFinalQC.zip
Already exists: 20-1711SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1711SR_CTDCast.zip
Already exists: 20-1711SR_CTDFinalQC.zip
Already unzipped: 20-1711SR_CTDFinalQC.zip
Already exists: 20-1708SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1708SR_CTDCast.zip
Already exists: 20-1708SR_CTDFinalQC.zip
Already unzipped: 20-1708SR_CTDFinalQC.zip
Already exists: 20-1704SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1704SH_CTDCast.zip
Already exists: 20-1704SH_CTDFinalQC.zip
Already unzipped: 20-1704SH_CTDFinalQC.zip
Already exists: 20-1701RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1701RL_CTDCast.zip
Already exists: 20-1701RL_CTDFinalQC.zip
Already unzipped: 20-1701RL_CTDFinalQC.zip
Already exists: 20-1611SR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1611SR_CTDCast.zip
Already exists: 20-1611SR_CTDFinalQC.zip
Already unzipped: 20-1611SR_CTDFinalQC.zip
Already exists: 20-1607OS_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1607OS_CTDCast.zip
Already exists: 20-1607OS_CTDFinalQC.zip
Already unzipped: 20-1607OS_CTDFinalQC.zip
Already exists: 20-1604SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1604SH_CTDCast.zip
Already exists: 20-1604SH_CTDFinalQC.zip
Already unzipped: 20-1604SH_CTDFinalQC.zip
Already exists: 20-1601RL_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1601RL_CTDCast.zip
Already exists: 20-1601RL_CTDFinalQC.zip
Already unzipped: 20-1601RL_CTDFinalQC.zip
Already exists: 20-1511OC_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1511OC_CTDCast.zip
Already exists: 20-1511OC_CTDFinalQC.zip
Already unzipped: 20-1511OC_CTDFinalQC.zip
Already exists: 20-1507OC_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1507OC_CTDCast.zip
Already exists: 20-1507OC_CTDFinalQC.zip
Already unzipped: 20-1507OC_CTDFinalQC.zip
Already exists: 20-1504NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1504NH_CTDCast.zip
Already exists: 20-1504NH_CTDFinalQC.zip
Already unzipped: 20-1504NH_CTDFinalQC.zip
Already exists: 20-1501NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1501NH_CTDCast.zip
Already exists: 20-1501NH_CTDFinalQC.zip
Already unzipped: 20-1501NH_CTDFinalQC.zip
Already exists: 20-1411NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1411NH_CTDCast.zip
Already exists: 20-1411NH_CTDFinalQC.zip
Already unzipped: 20-1411NH_CTDFinalQC.zip
Already exists: 20-1407NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1407NH_CTDCast.zip
Already exists: 20-1407NH_CTDFinalQC.zip
Already unzipped: 20-1407NH_CTDFinalQC.zip
Already exists: 20-1404OS_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1404OS_CTDCast.zip
Already exists: 20-1404OS_CTDFinalQC.zip
Already unzipped: 20-1404OS_CTDFinalQC.zip
Already exists: 20-1402SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1402SH_CTDCast.zip
Already exists: 20-1402SH_CTDFinalQC.zip
Already unzipped: 20-1402SH_CTDFinalQC.zip
Already exists: 20-1311NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1311NH_CTDCast.zip
Already exists: 20-1311NH_CTDFinalQC.zip
Already unzipped: 20-1311NH_CTDFinalQC.zip
Already exists: 20-1307NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1307NH_CTDCast.zip
Already exists: 20-1307NH_CTDFinalQC.zip
Already unzipped: 20-1307NH_CTDFinalQC.zip
Already exists: 20-1304SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1304SH_CTDCast.zip
Already exists: 20-1304SH_CTDFinalQC.zip
Already unzipped: 20-1304SH_CTDFinalQC.zip
Already exists: 20-1301SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1301SH_CTDCast.zip
Already exists: 20-1301SH_CTDFinalQC.zip
Already unzipped: 20-1301SH_CTDFinalQC.zip
Already exists: 20-1210NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1210NH_CTDCast.zip
Already exists: 20-1210NH_CTDFinalQC.zip
Already unzipped: 20-1210NH_CTDFinalQC.zip
Already exists: 20-1207OS_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1207OS_CTDCast.zip
Already exists: 20-1207OS_CTDFinalQC.zip
Already unzipped: 20-1207OS_CTDFinalQC.zip
Already exists: 20-1203NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1203NH_CTDCast.zip
Already exists: 20-1203SH_CTDFinalQC.zip
Already unzipped: 20-1203SH_CTDFinalQC.zip
Already exists: 20-1202NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1202NH_CTDCast.zip
Already exists: 20-1202NH_CTDFinalQC.zip
Already unzipped: 20-1202NH_CTDFinalQC.zip
Already exists: 20-1110NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1110NH_CTDCast.zip
Already exists: 20-1110NH_CTDFinalQC.zip
Already unzipped: 20-1110NH_CTDFinalQC.zip
Already exists: 20-1108NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1108NH_CTDCast.zip
Already exists: 20-1108NH_CTDFinalQC.zip
Already unzipped: 20-1108NH_CTDFinalQC.zip
Already exists: 20-1104SH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1104SH_CTDCast.zip
Already exists: 20-1104SH_CTDFinalQC.zip
Already unzipped: 20-1104SH_CTDFinalQC.zip
Already exists: 20-1101NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1101NH_CTDCast.zip
Already exists: 20-1101NH_CTDFinalQC.zip
Already unzipped: 20-1101NH_CTDFinalQC.zip
Already exists: 20-1011NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1011NH_CTDCast.zip
Already exists: 20-1011NH_CTDFinalQC.zip
Already unzipped: 20-1011NH_CTDFinalQC.zip
Already exists: 20-1008NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1008NH_CTDCast.zip
Already exists: 20-1008NH_CTDFinalQC.zip
Already unzipped: 20-1008NH_CTDFinalQC.zip
Already exists: 20-1004MF_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1004MF_CTDCast.zip
Already exists: 20-1004MF_CTDFinalQC.zip
Already unzipped: 20-1004MF_CTDFinalQC.zip
Already exists: 20-1001NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-1001NH_CTDCast.zip
Already exists: 20-1001NH_CTDFinalQC.zip
Already unzipped: 20-1001NH_CTDFinalQC.zip
Already exists: 20-0911NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0911NH_CTDCast.zip
Already exists: 20-0911NH_CTDFinalQC.zip
Already unzipped: 20-0911NH_CTDFinalQC.zip
Already exists: 20-0907M2_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0907M2_CTDCast.zip
Already exists: 20-0907M2_CTDFinalQC.zip
Already unzipped: 20-0907M2_CTDFinalQC.zip
Already exists: 20-0903JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0903JD_CTDCast.zip
Already exists: 20-0903JD_CTDFinalQC.zip
Already unzipped: 20-0903JD_CTDFinalQC.zip
Already exists: 20-0901NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0901NH_CTDCast.zip
Already exists: 20-0901NH_CTDFinalQC.zip
Already unzipped: 20-0901NH_CTDFinalQC.zip
Already exists: 20-0810NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0810NH_CTDCast.zip
Already exists: 20-0810NH_CTDFinalQC.zip
Already unzipped: 20-0810NH_CTDFinalQC.zip
Already exists: 20-0808NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0808NH_CTDCast.zip
Already exists: 20-0808NH_CTDFinalQC.zip
Already unzipped: 20-0808NH_CTDFinalQC.zip
Already exists: 20-0804JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0804JD_CTDCast.zip
Already exists: 20-0804JD_CTDFinalQC.zip
Already unzipped: 20-0804JD_CTDFinalQC.zip
Already exists: 20-0801JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0801JD_CTDCast.zip
Already exists: 20-0801JD_CTDFinalQC.zip
Already unzipped: 20-0801JD_CTDFinalQC.zip
Already exists: 20-0711NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0711NH_CTDCast.zip
Already exists: 20-0711NH_CTDFinalQC.zip
Already unzipped: 20-0711NH_CTDFinalQC.zip
Already exists: 20-0707NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0707NH_CTDCast.zip
Already exists: 20-0707NH_CTDFinalQC.zip
Already unzipped: 20-0707NH_CTDFinalQC.zip
Already exists: 20-0704JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0704JD_CTDCast.zip
Already exists: 20-0704JD_CTDFinalQC.zip
Already unzipped: 20-0704JD_CTDFinalQC.zip
Already exists: 20-0701JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0701JD_CTDCast.zip
Already exists: 20-0701JD_CTDFinalQC.zip
Already unzipped: 20-0701JD_CTDFinalQC.zip
Already exists: 20-0610RR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0610RR_CTDCast.zip
Already exists: 20-0610RR_CTDFinalQC.zip
Already unzipped: 20-0610RR_CTDFinalQC.zip
Already exists: 20-0607NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0607NH_CTDCast.zip
Already exists: 20-0607NH_CTDFinalQC.zip
Already unzipped: 20-0607NH_CTDFinalQC.zip
Already exists: 20-0604NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0604NH_CTDCast.zip
Already exists: 20-0604NH_CTDFinalQC.zip
Already unzipped: 20-0604NH_CTDFinalQC.zip
Already exists: 20-0602JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0602JD_CTDCast.zip
Already exists: 20-0602JD_CTDFinalQC.zip
Already unzipped: 20-0602JD_CTDFinalQC.zip
Already exists: 20-0511NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0511NH_CTDCast.zip
Already exists: 20-0511NH_CTDFinalQC.zip
Already unzipped: 20-0511NH_CTDFinalQC.zip
Already exists: 20-0507NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0507NH_CTDCast.zip
Already exists: 20-0507NH_CTDFinalQC.zip
Already unzipped: 20-0507NH_CTDFinalQC.zip
Already exists: 20-0504NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0504NH_CTDCast.zip
Already exists: 20-0504NH_CTDFinalQC.zip
Already unzipped: 20-0504NH_CTDFinalQC.zip
Already exists: 20-0501NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0501NH_CTDCast.zip
Already exists: 20-0501NH_CTDFinalQC.zip
Already unzipped: 20-0501NH_CTDFinalQC.zip
Already exists: 20-0411RR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0411RR_CTDCast.zip
Already exists: 20-0411RR_CTDFinalQC.zip
Already unzipped: 20-0411RR_CTDFinalQC.zip
Already exists: 20-0407JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0407JD_CTDCast.zip
Already exists: 20-0407JD_CTDFinalQC.zip
Already unzipped: 20-0407JD_CTDFinalQC.zip
Already exists: 20-0404NHJD_CTDFinalQC.zip
Already unzipped: 20-0404NHJD_CTDFinalQC.zip
Already exists: 20-0404NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0404NH_CTDCast.zip
Already exists: 20-0401JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0401JD_CTDCast.zip
Already exists: 20-0401JD_CTDFinalQC.zip
Already unzipped: 20-0401JD_CTDFinalQC.zip
Already exists: 20-0310NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0310NH_CTDCast.zip
Already exists: 20-0310NH_CTDFinalQC.zip
Already unzipped: 20-0310NH_CTDFinalQC.zip
Already exists: 20-0307NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0307NH_CTDCast.zip
Already exists: 20-0307NH_CTDFinalQC.zip
Already unzipped: 20-0307NH_CTDFinalQC.zip
Already exists: 20-0304JD_CTDFinalDB.zip
Already unzipped: 20-0304JD_CTDFinalDB.zip
Already exists: 20-0304RR_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0304RR_CTDCast.zip
Downloading: 20-0304RR_CTDFinalQC.zip
Already exists: 20-0302JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0302JD_CTDCast.zip
Already exists: 20-0302JD_CTDFinalQC.zip
Already unzipped: 20-0302JD_CTDFinalQC.zip
Already exists: 20-0211NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0211NH_CTDCast.zip
Downloading: 20-0211NH_CTDFinalQC.zip
Unzipping: 20-0211NH_CTDFinalQC.zip
Already exists: 20-0207NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0207NH_CTDCast.zip
Downloading: 20-0207NH_CTDFinalQC.zip
Unzipping: 20-0207NH_CTDFinalQC.zip
Already exists: 20-0204JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0204JD_CTDCast.zip
Downloading: 20-0204JD_CTDFinalQC.zip
Already exists: 20-0201JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0201JD_CTDCast.zip
Downloading: 20-0201JD_CTDFinalQC.zip
Already exists: 20-0110NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0110NH_CTDCast.zip
Downloading: 20-0110NH_CTDFinalQC.zip
Unzipping: 20-0110NH_CTDFinalQC.zip
Already exists: 20-0107NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0107NH_CTDCast.zip
Downloading: 20-0107NH_CTDFinalQC.zip
Unzipping: 20-0107NH_CTDFinalQC.zip
Already exists: 20-0104JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0104JD_CTDCast.zip
Downloading: 20-0104JD_CTDFinalQC.zip
Already exists: 20-0101JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0101JD_CTDCast.zip
Downloading: 20-0101JD_CTDFinalQC.zip
Unzipping: 20-0101JD_CTDFinalQC.zip
Already exists: 20-0010NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0010NH_CTDCast.zip
Downloading: 20-0010NH_CTDFinalQC.zip
Unzipping: 20-0010NH_CTDFinalQC.zip
Already exists: 20-0007NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0007NH_CTDCast.zip
Downloading: 20-0007NH_CTDFinalQC.zip
Unzipping: 20-0007NH_CTDFinalQC.zip
Already exists: 20-0004JD_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0004JD_CTDCast.zip
Downloading: 20-0004JD_CTDFinalQC.zip
Unzipping: 20-0004JD_CTDFinalQC.zip
Already exists: 20-0001NH_CTDCast.zip
Skipping unzip (not final/preliminary): 20-0001NH_CTDCast.zip
Downloading: 20-0001NH_CTDFinalQC.zip
Unzipping: 20-0001NH_CTDFinalQC.zip
Already exists: 19-9910NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9910NH_CTDCast.zip
Downloading: 19-9910NH_CTDFinalQC.zip
Unzipping: 19-9910NH_CTDFinalQC.zip
Already exists: 19-9908NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9908NH_CTDCast.zip
Downloading: 19-9908NH_CTDFinalQC.zip
Already exists: 19-9904JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9904JD_CTDCast.zip
Downloading: 19-9904JD_CTDFinalQC.zip
Already exists: 19-9901RR_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9901RR_CTDCast.zip
Downloading: 19-9901RR_CTDFinalQC.zip
Already exists: 19-9812SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9812SP_CTDCast.zip
Downloading: 19-9812SP_CTDFinalQC.zip
Already exists: 19-9811SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9811SP_CTDCast.zip
Downloading: 19-9811SP_CTDFinalQC.zip
Already exists: 19-9810SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9810SP_CTDCast.zip
Downloading: 19-9810SP_CTDFinalQC.zip
Already exists: 19-9809NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9809NH_CTDCast.zip
Downloading: 19-9809NH_CTDFinalQC.zip
Already exists: 19-9808SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9808SP_CTDCast.zip
Downloading: 19-9808SP_CTDFinalQC.zip
Already exists: 19-9807NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9807NH_CTDCast.zip
Already exists: 19-9807NH_CTDFinalQC.zip
Already unzipped: 19-9807NH_CTDFinalQC.zip
Already exists: 19-9806SP_CTDFinalDB.zip
Already unzipped: 19-9806SP_CTDFinalDB.zip
Already exists: 19-9805SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9805SP_CTDCast.zip
Downloading: 19-9805SP_CTDFinalQC.zip
Already exists: 19-9804JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9804JD_CTDCast.zip
Already exists: 19-9804JD_CTDFinalQC.zip
Already unzipped: 19-9804JD_CTDFinalQC.zip
Already exists: 19-9803SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9803SP_CTDCast.zip
Downloading: 19-9803SP_CTDFinalQC.zip
Already exists: 19-9802JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9802JD_CTDCast.zip
Already exists: 19-9802JD_CTDFinalQC.zip
Already unzipped: 19-9802JD_CTDFinalQC.zip
Already exists: 19-9712SP_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9712SP_CTDCast.zip
Already exists: 19-9712SP_CTDFinalDB.zip
Already unzipped: 19-9712SP_CTDFinalDB.zip
Already exists: 19-9709NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9709NH_CTDCast.zip
Already exists: 19-9709NH_CTDFinalDB.zip
Already unzipped: 19-9709NH_CTDFinalDB.zip
Already exists: 19-9707JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9707JD_CTDCast.zip
Already exists: 19-9707JD_CTDFinalDB.zip
Already unzipped: 19-9707JD_CTDFinalDB.zip
Already exists: 19-9704NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9704NH_CTDCast.zip
Already exists: 19-9704NH_CTDFinalDB.zip
Already unzipped: 19-9704NH_CTDFinalDB.zip
Already exists: 19-9702JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9702JD_CTDCast.zip
Already exists: 19-9702JD_CTDFinalDB.zip
Already unzipped: 19-9702JD_CTDFinalDB.zip
Already exists: 19-9610RR_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9610RR_CTDCast.zip
Already exists: 19-9610RR_CTDFinalDB.zip
Already unzipped: 19-9610RR_CTDFinalDB.zip
Already exists: 19-9608NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9608NH_CTDCast.zip
Already exists: 19-9608NH_CTDFinalDB.zip
Already unzipped: 19-9608NH_CTDFinalDB.zip
Already exists: 19-9604JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9604JD_CTDCast.zip
Already exists: 19-9604JD_CTDFinalDB.zip
Already unzipped: 19-9604JD_CTDFinalDB.zip
Already exists: 19-9602JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9602JD_CTDCast.zip
Already exists: 19-9602JD_CTDFinalDB.zip
Already unzipped: 19-9602JD_CTDFinalDB.zip
Already exists: 19-9510NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9510NH_CTDCast.zip
Already exists: 19-9510NH_CTDFinalDB.zip
Already unzipped: 19-9510NH_CTDFinalDB.zip
Already exists: 19-9507JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9507JD_CTDCast.zip
Already exists: 19-9507JD_CTDFinalDB.zip
Already unzipped: 19-9507JD_CTDFinalDB.zip
Already exists: 19-9504NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9504NH_CTDCast.zip
Already exists: 19-9504NH_CTDFinalDB.zip
Already unzipped: 19-9504NH_CTDFinalDB.zip
Already exists: 19-9501JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9501JD_CTDCast.zip
Already exists: 19-9501JD_CTDFinalDB.zip
Already unzipped: 19-9501JD_CTDFinalDB.zip
Already exists: 19-9410NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9410NH_CTDCast.zip
Already exists: 19-9410NH_CTDFinalDB.zip
Already unzipped: 19-9410NH_CTDFinalDB.zip
Already exists: 19-9408NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9408NH_CTDCast.zip
Already exists: 19-9408NH_CTDFinalDB.zip
Already unzipped: 19-9408NH_CTDFinalDB.zip
Already exists: 19-9403JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9403JD_CTDCast.zip
Already exists: 19-9403JD_CTDFinalDB.zip
Already unzipped: 19-9403JD_CTDFinalDB.zip
Already exists: 19-9401JD_CTDCast.zip
Already unzipped: 19-9401JD_CTDCast.zip
Already exists: 19-9401JD_CTDFinalDB.zip
Already unzipped: 19-9401JD_CTDFinalDB.zip
Already exists: 19-9310NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9310NH_CTDCast.zip
Already exists: 19-9310NH_CTDFinalDB.zip
Already unzipped: 19-9310NH_CTDFinalDB.zip
Already exists: 19-9308NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9308NH_CTDCast.zip
Already exists: 19-9308NH_CTDFinalDB.zip
Already unzipped: 19-9308NH_CTDFinalDB.zip
Already exists: 19-9304JD_CTDTest.zip
Skipping unzip (not final/preliminary): 19-9304JD_CTDTest.zip
Already exists: 19-9301JD_CTDTest.zip
Skipping unzip (not final/preliminary): 19-9301JD_CTDTest.zip
Already exists: 19-9210NH_CTDCastProdoStas.zip
Skipping unzip (not final/preliminary): 19-9210NH_CTDCastProdoStas.zip
Already exists: 19-9210NH_CTDTest.zip
Skipping unzip (not final/preliminary): 19-9210NH_CTDTest.zip
Already exists: 19-9207NH_CTDCastProdoStas.zip
Skipping unzip (not final/preliminary): 19-9207NH_CTDCastProdoStas.zip
Already exists: 19-9204JD_CTDTest01.zip
Skipping unzip (not final/preliminary): 19-9204JD_CTDTest01.zip
Already exists: 19-9202JD_CTDCastProdoStas.zip
Skipping unzip (not final/preliminary): 19-9202JD_CTDCastProdoStas.zip
Already exists: 19-9110NH_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9110NH_CTDCast.zip
Already exists: 19-9108JD_CTDCast.zip
Skipping unzip (not final/preliminary): 19-9108JD_CTDCast.zip
Already exists: 19-9103JD_CTDTest.zip
Skipping unzip (not final/preliminary): 19-9103JD_CTDTest.zip
Already exists: 19-9101JD_CTDTest.zip
Skipping unzip (not final/preliminary): 19-9101JD_CTDTest.zip
Already exists: 19-9011NH_CTDTest.zip
Skipping unzip (not final/preliminary): 19-9011NH_CTDTest.zip
Already exists: 19-9003JD_CTDTest.zip
Already unzipped: 19-9003JD_CTDTest.zip
Code
say("Download and extraction complete!")
Download and extraction complete!

7.1 Overlap guard

Skipping an archive is a claim that nothing is lost by skipping it, so measure the claim on every overlapping cruise rather than trusting one sample. This reads the zip directory (unzip -l, no extraction) and compares each top-level _CTDBTL_*.csv against calcofi.org’s copy of the same filename by uncompressed size — a few seconds for all 66, where md5 would mean unpacking ~2 GB.

Size equality is a screen, not a proof; what it reliably catches is the case that matters, a Shared-Drive copy holding materially more (or less) than the published one. It does not halt the render: a difference here is a question for the provider, not a defect in this pipeline, and the release keeps what it already had either way.

Code
# JRW ships the db CSVs at the archive TOP LEVEL; calcofi.org buries the same
# filenames under db_csv(s)/ inside the _CTDFinalQC archive. Match on basename —
# several cruises split a cruise across separate runs (013-074, 044-060, ...) so
# the file SETS are not always the same shape and a positional compare would lie.
web_btl <- tibble(
  path = list.files(
    dir_dl, pattern = "_CTDBTL_.*\\.csv$", full.names = TRUE, recursive = TRUE)) |>
  filter(str_detect(path, "_CTDFinalQC/db[_-]csvs?/")) |>
  mutate(file_csv = basename(path), web_bytes = as.numeric(file_size(path))) |>
  select(file_csv, web_bytes) |>
  distinct(file_csv, .keep_all = TRUE)

# uncompressed sizes straight out of the zip directory — no extraction.
#
# utils::unzip, NOT the bare name: this notebook shelves the `zip` package, whose
# unzip() masks base R's and takes no `list` argument, so `unzip(list = TRUE)`
# fails with "unused argument (list = TRUE)". Everywhere else in this notebook
# unzip() is called only to EXTRACT, where the two signatures happen to agree —
# which is why the masking had never surfaced before.
jrw_btl <- d_zips_skip$file_zip |>
  set_names() |>
  map_dfr(\(fz) {
    z <- utils::unzip(file.path(dir_dl, fz), list = TRUE)
    tibble(file_csv = basename(z$Name), depth = str_count(z$Name, "/"),
           jrw_bytes = z$Length) |>
      filter(str_detect(file_csv, "_CTDBTL_.*\\.csv$"), depth == 0)
  }, .id = "file_zip") |>
  select(-depth)

d_overlap <- jrw_btl |>
  left_join(web_btl, by = "file_csv") |>
  mutate(
    cruise_key = str_extract(file_zip, "\\d{2}-(\\d{4}[A-Z0-9]{2,4})", group = 1),
    ratio  = round(jrw_bytes / web_bytes, 3),
    status = case_when(
      is.na(web_bytes)        ~ "no matching filename on calcofi.org",
      jrw_bytes == web_bytes  ~ "identical size",
      TRUE                    ~ "DIFFERENT size")) |>
  relocate(cruise_key, file_csv, status, ratio, jrw_bytes, web_bytes)

n_same <- sum(d_overlap$status == "identical size")
n_diff <- sum(d_overlap$status == "DIFFERENT size")
n_none <- sum(d_overlap$status == "no matching filename on calcofi.org")

say(glue(
  "Overlap comparison across {n_distinct(d_overlap$cruise_key)} cruises / ",
  "{nrow(d_overlap)} files: {n_same} identical, {n_diff} different, ",
  "{n_none} with no same-named counterpart (split runs)"))
Overlap comparison across 90 cruises / 188 files: 148 identical, 10 different, 30 with no same-named counterpart (split runs)
Code
# Not a hard stop — but a WHOLESALE mismatch would mean the wrong folder was
# synced or the layout changed, which is a defect rather than a data question.
stopifnot(
  "no comparable overlap files at all — check the sync and the archive layout" =
    nrow(d_overlap) > 0,
  "the two sources agree on almost nothing — this is a sync/layout problem, not a data question" =
    n_same >= 0.5 * (n_same + n_diff))

d_overlap |>
  filter(status != "identical size") |>
  arrange(desc(ratio)) |>
  dt(
    caption = glue(
      "Shared-Drive vs calcofi.org finals that do NOT match ({n_diff} different ",
      "size, {n_none} split-run). calcofi.org is used for all of these; see ",
      "question calcofi_ctd-cast_20"),
    fname = "ctd_jrw_overlap_diffs"
  )

8 Find and Prioritize CTD Data Files

CalCOFI’s CTD Cast Files page names six categories across its header row, three of which are 1 m-binned products that supersede one another:

Preliminary CTD 1m-Binned → Preliminary CTD & Bottle 1m-Binned → Final 1m-Binned

but each cruise offers a single _CTDPrelim.zip, replaced in place as processing advances, so the two preliminary tiers are invisible from the zip name. The discriminator is the CSV filename inside the archive — _CTDBTL_ once the bottle merge has happened, plain _CTD_ before it:

archive CSV data_stage
_CTDFinalQC.zip (calcofi.org) db_csvs/…_CTDBTL_…csv final
_CTDFinalDB.zip (Shared Drive) …_CTDBTL_…csv at top level final
_CTDPrelim.zip db-csvs/…_CTDBTL_…csv preliminary_with_bottle
_CTDPrelim.zip …_CTD_…csv preliminary_without_bottle

This matters well beyond labelling. Only the station-corrected salinity and oxygen are canonical — following the provider’s own guidance that “station-corrected data are the best” — and those wait on the bottle merge. A preliminary_without_bottle cruise therefore carries no corrected salinity or oxygen at all, which used to be indistinguishable from a preliminary_with_bottle cruise that has them. Splitting the tier is what lets a consumer tell “not measured” from “not merged yet”.

Code
d_csv <- tibble(
  path = list.files(
    dir_dl,
    pattern = "\\.csv$",
    recursive = TRUE,
    full.names = TRUE
  )
) |>
  mutate(
    file_csv = basename(path),
    path_unzip = str_replace(path, glue("{dir_dl}/"), ""),
    dir_unzip = str_extract(path_unzip, "^[^/]+"),
    cruise_key = str_extract(
      path_unzip,
      "\\d{2}-(\\d{4}[A-Z0-9]{2,4})_.*",
      group = 1
    ),
    data_stage = case_when(
      # final, calcofi.org: db_csv(s)/ inside a *Final* archive
      str_detect(path_unzip, "Final.*db[_|-]csv")  ~ "final",
      # final, Shared Drive: JRW's *_CTDFinalDB archives put the db CSVs at the
      # TOP LEVEL. The top-level restriction is load-bearing — these archives also
      # carry orig/, orig_dbcsvs/, SeparateRuns_Fl/ and south-north/ copies of the
      # same casts. Without it both copies classify `final`, and which one survives
      # falls to the `ORDER BY length("_source_file")` tiebreak in dedup_ctd_raw:
      # correct by accident, and only until a path length changes.
      str_detect(dir_unzip, "CTDFinalDB$") &
        path_unzip == paste0(dir_unzip, "/", file_csv) ~ "final",
      # preliminary, bottle-merged: _CTDBTL_ means the bottle merge has run
      str_detect(dir_unzip, "Prelim") &
        str_detect(file_csv, "_CTDBTL_") ~ "preliminary_with_bottle",
      # preliminary, sensor only: _CTD_ (no BTL) — corrected salinity/oxygen and
      # every bottle column are empty in these
      str_detect(dir_unzip, "Prelim") &
        str_detect(file_csv, "_CTD_") ~ "preliminary_without_bottle",
      # NOTE: the hardcoded `cruise_key == "2111SR"` arm that used to sit here is
      # gone, and deliberately. 2111SR puts its db CSVs in csvs-plots/ rather than
      # db-csvs/, so the old *directory*-based rules missed it and it needed a
      # cruise-specific path regex. The rules above key on the FILENAME token and
      # do not care how deep the file sits, so
      # 20-2111SR_CTDPrelim/20-2111SRCTDPrelim/csvs-plots/20-2111SR_CTDBTL_001-075D.csv
      # now classifies as preliminary_with_bottle on its own — which is also
      # accurate than the old arm, which could only say "preliminary". Verified
      # against the archive on disk; a regression would trip the content
      # assertion in [emit_core].
      .default = NA_character_
    ),
    # The direction letter is the LAST character of the name — and "last" has to
    # survive a copy marker. `dir_dl` sits inside Google Drive, which names a
    # conflict copy `19-9501JD_CTDBTL_001-646D 2.csv`; that ends in " 2.csv",
    # matched neither arm of the old `…D\\.csv$` test, and came through as NA.
    # Not cosmetic: [ctd_measurement] joins on cast_dir and NULL = NULL is never
    # true, and [ctd_thin] needs a downcast. Strip the marker, then read the
    # letter off the stem.
    file_stem = file_csv |>
      str_remove("\\.csv$") |>
      str_remove("\\s*\\(\\d+\\)$") |>          # "name (2).csv"
      str_remove("\\s+\\d+$"),                  # "name 2.csv"
    cast_dir = case_when(
      str_detect(file_stem, regex("U$", ignore_case = T)) ~ "U",
      str_detect(file_stem, regex("D$", ignore_case = T)) ~ "D"
    ),
    # supersession order, straight off CTD_DATA_STAGES so a new stage cannot be
    # added to the vocabulary without also being ranked (an unranked stage would
    # otherwise sort last and be silently discarded by the filter below)
    priority = match(data_stage, CTD_DATA_STAGES)
  ) |>
  relocate(cruise_key, path_unzip) |>
  arrange(cruise_key, path_unzip) |>
  filter(data_stage %in% CTD_DATA_STAGES) |>
  # An archive skipped in [pick_archives] is never unzipped — but this chunk globs
  # the download directory, not the inventory, so a copy left behind by an earlier
  # run (before the skip existed, or from a run where it was disabled) would still
  # be found here and would compete with calcofi.org's identical copy. Drop them by
  # their unzip directory so the skip decision holds regardless of what is on disk.
  filter(!dir_unzip %in% str_remove(d_zips_skip$file_zip, "\\.zip$"))

# for each cruise keep only the most-advanced stage available:
# final > preliminary_with_bottle > preliminary_without_bottle
d_priority <- d_csv |>
  group_by(cruise_key) |>
  summarize(
    best_priority = min(priority),
    .groups = "drop"
  )

d_csv <- d_csv |>
  inner_join(d_priority, by = "cruise_key") |>
  filter(priority == best_priority) |>
  select(-best_priority)

# A file whose name does not END in a direction letter is not a db product this
# ingest reads. Four exist — `…084IDnoQC.csv` and `…067Dno001b.csv` pairs — and
# they sit beside the canonical D/U pair inside the same archive. Carrying them
# with `cast_dir = NA` was strictly worse than dropping them: [ctd_measurement]
# joins on cast_dir, `NULL = NULL` is never true, so they became 112,245 cast
# rows that could hold no measurement and still reached `sample` as casts with
# nothing under them. Drop them, and say which.
d_csv_nodir <- d_csv |> filter(is.na(cast_dir))
d_csv       <- d_csv |> filter(!is.na(cast_dir))

if (nrow(d_csv_nodir) > 0)
  d_csv_nodir |>
    select(cruise_key, file_csv, data_stage) |>
    arrange(cruise_key, file_csv) |>
    dt(
      caption = glue(
        "Skipped: {nrow(d_csv_nodir)} file(s) whose name resolves no cast ",
        "direction (alternate renderings beside the canonical D/U pair)"),
      fname = "ctd_files_no_cast_direction")
Code
# Every cruise must keep a downcast. [ctd_thin] takes one direction per physical
# cast and prefers D, so a cruise with no D produces no thin rows and therefore
# no `obs` — and because write_parquet_outputs() DELETES a partition that has
# left the data, and sync_to_gcs() mirrors the deletion, the cruise then leaves
# the release entirely while its casts survive in `sample`. That is precisely how
# v2026.08.08 lost 10 cruises and 874,000 observations with no error anywhere.
cruises_no_downcast <- d_csv |>
  group_by(cruise_key) |>
  summarize(has_downcast = any(cast_dir == "D"), .groups = "drop") |>
  filter(!has_downcast) |>
  pull(cruise_key)

if (length(cruises_no_downcast) > 0)
  stop(glue(
    "{length(cruises_no_downcast)} cruise(s) have no downcast file: ",
    "{paste(cruises_no_downcast, collapse = ', ')}.\n",
    "ctd_thin cannot emit a headline profile for them, so they would reach the ",
    "release as casts with no observations."))

cruises_csv_notzip <- setdiff(
  unique(d_csv$cruise_key),
  unique(d_zips$cruise_key)
) |>
  sort()
stopifnot(length(cruises_csv_notzip) == 0)

cruises_zip_notcsv <- setdiff(
  unique(d_zips$cruise_key),
  unique(d_csv$cruise_key)
) |>
  sort()

d_csv |>
  select(-any_of(c("path", "data", "col_empties", "col_types"))) |>
  relocate(cruise_key, path_unzip) |>
  arrange(cruise_key, path_unzip) |>
  dt(
    caption = "Files to Ingest",
    fname = "ctd_files_to_ingest"
  )

9 Read and Standardize CTD Files

Remove repeat header rows that can occur within files.

Code
d_csv <- d_csv |>
  arrange(basename(path)) |>
  mutate(
    data = map2(path, seq_along(path), \(path, idx) {
      say(glue("Reading {idx}/{nrow(d_csv)}: {basename(path)}"))

      all_lines <- read_lines(path)
      header_line <- all_lines[1]

      # find repeat header rows (excluding row 1)
      repeat_header_rows <- which(all_lines[-1] == header_line)

      if (length(repeat_header_rows) > 0) {
        all_lines <- all_lines[-(repeat_header_rows + 1)]
        tmp_file <- tempfile(fileext = ".csv")
        write_lines(all_lines, tmp_file)
        data <- read_csv(tmp_file, guess_max = Inf, show_col_types = F) |>
          clean_names()
        file_delete(tmp_file)
      } else {
        data <- read_csv(path, guess_max = Inf, show_col_types = F) |>
          clean_names()
      }
      data
    }),
    nrows = map_int(data, ~ if (is.null(.x)) 0 else nrow(.x))
  )
Reading 1/361: 19-9308NH_CTDBTL_001-066ID.csv
Reading 2/361: 19-9308NH_CTDBTL_001-066IU.csv
Reading 3/361: 19-9310NH_CTDBTL_001-066D.csv
Reading 4/361: 19-9310NH_CTDBTL_001-066U.csv
Reading 5/361: 19-9401JD_CTDBTL_001-066D.csv
Reading 6/361: 19-9401JD_CTDBTL_001-066ID.csv
Reading 7/361: 19-9401JD_CTDBTL_001-066IU.csv
Reading 8/361: 19-9401JD_CTDBTL_001-066U.csv
Reading 9/361: 19-9403JD_CTDBTL_001-066D.csv
Reading 10/361: 19-9403JD_CTDBTL_001-066ID.csv
Reading 11/361: 19-9403JD_CTDBTL_001-066IU.csv
Reading 12/361: 19-9403JD_CTDBTL_001-066U.csv
Reading 13/361: 19-9408NH_CTDBTL_001-066D.csv
Reading 14/361: 19-9408NH_CTDBTL_001-066U.csv
Reading 15/361: 19-9410NH_CTDBTL_001-066D.csv
Reading 16/361: 19-9410NH_CTDBTL_001-066U.csv
Reading 17/361: 19-9501JD_CTDBTL_001-646D.csv
Reading 18/361: 19-9501JD_CTDBTL_001-646U.csv
Reading 19/361: 19-9504NH_CTDBTL_001-061D.csv
Reading 20/361: 19-9504NH_CTDBTL_001-061ID.csv
Reading 21/361: 19-9504NH_CTDBTL_001-061IU.csv
Reading 22/361: 19-9504NH_CTDBTL_001-061U.csv
Reading 23/361: 19-9507JD_CTDBTL_001-066D.csv
Reading 24/361: 19-9507JD_CTDBTL_001-066U.csv
Reading 25/361: 19-9510NH_CTDBTL_001-066D.csv
Reading 26/361: 19-9510NH_CTDBTL_001-066ID.csv
Reading 27/361: 19-9510NH_CTDBTL_001-066IU.csv
Reading 28/361: 19-9510NH_CTDBTL_001-066U.csv
Reading 29/361: 19-9602JD_CTDBTL_001-066D.csv
Reading 30/361: 19-9602JD_CTDBTL_001-066U.csv
Reading 31/361: 19-9604JD_CTDBTL_001-064D.csv
Reading 32/361: 19-9604JD_CTDBTL_001-064U.csv
Reading 33/361: 19-9608NH_CTDBTL_001-066D.csv
Reading 34/361: 19-9608NH_CTDBTL_001-066U.csv
Reading 35/361: 19-9610RR_CTDBTL_001-066D.csv
Reading 36/361: 19-9610RR_CTDBTL_001-066U.csv
Reading 37/361: 19-9702NH_CTDBTL_001-070D.csv
Reading 38/361: 19-9702NH_CTDBTL_001-070U.csv
Reading 39/361: 19-9704NH_CTDBTL_001-762D.csv
Reading 40/361: 19-9704NH_CTDBTL_001-762U.csv
Reading 41/361: 19-9707JD_CTDBTL_001-066D.csv
Reading 42/361: 19-9707JD_CTDBTL_001-066U.csv
Reading 43/361: 19-9709NH_CTDBTL_001-659D.csv
Reading 44/361: 19-9709NH_CTDBTL_001-659U.csv
Reading 45/361: 19-9712SP_CTDBTL_001-009D.csv
Reading 46/361: 19-9712SP_CTDBTL_001-009U.csv
Reading 47/361: 19-9802JD_CTDBTL_001-066D.csv
Reading 48/361: 19-9802JD_CTDBTL_001-066U.csv
Reading 49/361: 19-9804JD_CTDBTL_001-085D.csv
Reading 50/361: 19-9804JD_CTDBTL_001-085U.csv
Reading 51/361: 19-9806SP_CTDBTL_001-020D.csv
Reading 52/361: 19-9806SP_CTDBTL_001-020U.csv
Reading 53/361: 19-9910NH_CTDBTL_001-028ID.csv
Reading 54/361: 19-9910NH_CTDBTL_001-028IU.csv
Reading 55/361: 19-9910NH_CTDBTL_001-637D.csv
Reading 56/361: 19-9910NH_CTDBTL_001-637U.csv
Reading 57/361: 19-9910NH_CTDBTL_029-637ID.csv
Reading 58/361: 19-9910NH_CTDBTL_029-637IU.csv
Reading 59/361: 19-9910NH_CTDBTL_053-055ID.csv
Reading 60/361: 19-9910NH_CTDBTL_053-055IU.csv
Reading 61/361: 19-9910NH_CTDBTL_058-072ID.csv
Reading 62/361: 19-9910NH_CTDBTL_058-072IU.csv
Reading 63/361: 20-0001NH_CTDBTL_001-029ID.csv
Reading 64/361: 20-0001NH_CTDBTL_001-029IU.csv
Reading 65/361: 20-0001NH_CTDBTL_001-066D.csv
Reading 66/361: 20-0001NH_CTDBTL_001-066U.csv
Reading 67/361: 20-0001NH_CTDBTL_030-066ID.csv
Reading 68/361: 20-0001NH_CTDBTL_030-066IU.csv
Reading 69/361: 20-0004JD_CTDBTL_001-066ID.csv
Reading 70/361: 20-0004JD_CTDBTL_001-066IU.csv
Reading 71/361: 20-0007NH_CTDBTL_001-066ID.csv
Reading 72/361: 20-0007NH_CTDBTL_001-066IU.csv
Reading 73/361: 20-0010NH_CTDBTL_001-074ID.csv
Reading 74/361: 20-0010NH_CTDBTL_001-074IU.csv
Reading 75/361: 20-0010NH_CTDBTL_002-074D.csv
Reading 76/361: 20-0010NH_CTDBTL_002-074U.csv
Reading 77/361: 20-0101JD_CTDBTL_001-023ID.csv
Reading 78/361: 20-0101JD_CTDBTL_001-023IU.csv
Reading 79/361: 20-0101JD_CTDBTL_001-066D.csv
Reading 80/361: 20-0101JD_CTDBTL_001-066U.csv
Reading 81/361: 20-0101JD_CTDBTL_024-039ID.csv
Reading 82/361: 20-0101JD_CTDBTL_024-039IU.csv
Reading 83/361: 20-0101JD_CTDBTL_040-066ID.csv
Reading 84/361: 20-0101JD_CTDBTL_040-066IU.csv
Reading 85/361: 20-0107NH_CTDBTL_001-012ID.csv
Reading 86/361: 20-0107NH_CTDBTL_001-012IU.csv
Reading 87/361: 20-0107NH_CTDBTL_001-066D.csv
Reading 88/361: 20-0107NH_CTDBTL_001-066U.csv
Reading 89/361: 20-0107NH_CTDBTL_013-066ID.csv
Reading 90/361: 20-0107NH_CTDBTL_013-066IU.csv
Reading 91/361: 20-0110NH_CTDBTL_001-066D.csv
Reading 92/361: 20-0110NH_CTDBTL_001-066U.csv
Reading 93/361: 20-0207NH_CTDBTL_001-066D.csv
Reading 94/361: 20-0207NH_CTDBTL_001-066U.csv
Reading 95/361: 20-0211NH_CTDBTL_001-066D.csv
Reading 96/361: 20-0211NH_CTDBTL_001-066U.csv
Reading 97/361: 20-0302JD_CTDBTL_001-073ID.csv
Reading 98/361: 20-0302JD_CTDBTL_001-073IU.csv
Reading 99/361: 20-0302JD_CTDBTL_001-100D.csv
Reading 100/361: 20-0302JD_CTDBTL_001-100U.csv
Reading 101/361: 20-0302JD_CTDBTL_074-100ID.csv
Reading 102/361: 20-0302JD_CTDBTL_074-100IU.csv
Reading 103/361: 20-0304JD_CTDBTL_068-167ID.csv
Reading 104/361: 20-0304JD_CTDBTL_068-167IU.csv
Reading 105/361: 20-0307NH_CTDBTL_001-526ID.csv
Reading 106/361: 20-0307NH_CTDBTL_001-526IU.csv
Reading 107/361: 20-0307NH_CTDBTL_044-060ID.csv
Reading 108/361: 20-0307NH_CTDBTL_044-060IU.csv
Reading 109/361: 20-0310NH_CTDBTL_001-066D.csv
Reading 110/361: 20-0310NH_CTDBTL_001-066U.csv
Reading 111/361: 20-0401JD_CTDBTL_001-098ID.csv
Reading 112/361: 20-0401JD_CTDBTL_001-098IU.csv
Reading 113/361: 20-0404JD_CTDBTL_067-094ID.csv
Reading 114/361: 20-0404JD_CTDBTL_067-094IU.csv
Reading 115/361: 20-0404NH_CTDBTL_001-066ID.csv
Reading 116/361: 20-0404NH_CTDBTL_001-066IU.csv
Reading 117/361: 20-0407JD_CTDBTL_001-002ID.csv
Reading 118/361: 20-0407JD_CTDBTL_001-002IU.csv
Reading 119/361: 20-0407JD_CTDBTL_001-012ID.csv
Reading 120/361: 20-0407JD_CTDBTL_001-012IU.csv
Reading 121/361: 20-0407JD_CTDBTL_003-012ID.csv
Reading 122/361: 20-0407JD_CTDBTL_003-012IU.csv
Reading 123/361: 20-0407JD_CTDBTL_013-028ID.csv
Reading 124/361: 20-0407JD_CTDBTL_013-028IU.csv
Reading 125/361: 20-0407JD_CTDBTL_013-074ID.csv
Reading 126/361: 20-0407JD_CTDBTL_013-074IU.csv
Reading 127/361: 20-0407JD_CTDBTL_029-039ID.csv
Reading 128/361: 20-0407JD_CTDBTL_029-039IU.csv
Reading 129/361: 20-0407JD_CTDBTL_040-074ID.csv
Reading 130/361: 20-0407JD_CTDBTL_040-074IU.csv
Reading 131/361: 20-0411RR_CTDBTL_001-965ID.csv
Reading 132/361: 20-0411RR_CTDBTL_001-965IU.csv
Reading 133/361: 20-0501NH_CTDBTL_001-075D.csv
Reading 134/361: 20-0501NH_CTDBTL_001-075U.csv
Reading 135/361: 20-0504NH_CTDBTL_001-075D.csv
Reading 136/361: 20-0504NH_CTDBTL_001-075U.csv
Reading 137/361: 20-0507NH_CTDBTL_001-075D.csv
Reading 138/361: 20-0507NH_CTDBTL_001-075U.csv
Reading 139/361: 20-0511NH_CTDBTL_001-072D.csv
Reading 140/361: 20-0511NH_CTDBTL_001-072U.csv
Reading 141/361: 20-0602JD_CTDBTL_001-096D.csv
Reading 142/361: 20-0602JD_CTDBTL_001-096U.csv
Reading 143/361: 20-0604NH_CTDBTL_001-563ID.csv
Reading 144/361: 20-0604NH_CTDBTL_001-563IU.csv
Reading 145/361: 20-0607NH_CTDBTL_001-074ID.csv
Reading 146/361: 20-0607NH_CTDBTL_001-074IU.csv
Reading 147/361: 20-0610RR_CTDBTL_001-567D.csv
Reading 148/361: 20-0610RR_CTDBTL_001-567U.csv
Reading 149/361: 20-0701JD_CTDBTL_001-084D.csv
Reading 150/361: 20-0701JD_CTDBTL_001-084U.csv
Reading 151/361: 20-0704JD_CTDBTL_001-555ID.csv
Reading 152/361: 20-0704JD_CTDBTL_001-555IU.csv
Reading 153/361: 20-0707NH_CTDBTL_001-012ID.csv
Reading 154/361: 20-0707NH_CTDBTL_001-012IU.csv
Reading 155/361: 20-0707NH_CTDBTL_001-073ID.csv
Reading 156/361: 20-0707NH_CTDBTL_001-073IU.csv
Reading 157/361: 20-0707NH_CTDBTL_013-046ID.csv
Reading 158/361: 20-0707NH_CTDBTL_013-046IU.csv
Reading 159/361: 20-0707NH_CTDBTL_047-073ID.csv
Reading 160/361: 20-0707NH_CTDBTL_047-073IU.csv
Reading 161/361: 20-0711_CTDBTL_001-067ID.csv
Reading 162/361: 20-0711_CTDBTL_001-067IU.csv
Reading 163/361: 20-0801JD_CTDBTL_001-545ID.csv
Reading 164/361: 20-0801JD_CTDBTL_001-545IU.csv
Reading 165/361: 20-0801_CTDBTL_001-545D.csv
Reading 166/361: 20-0801_CTDBTL_001-545ID.csv
Reading 167/361: 20-0801_CTDBTL_001-545IU.csv
Reading 168/361: 20-0801_CTDBTL_001-545U.csv
Reading 169/361: 20-0804JD_CTDBTL_001-177ID.csv
Reading 170/361: 20-0804JD_CTDBTL_001-177IU.csv
Reading 171/361: 20-0808_CTDBTL_001-069D.csv
Reading 172/361: 20-0808_CTDBTL_001-069U.csv
Reading 173/361: 20-0810NH_CTDBTL_001-073ID.csv
Reading 174/361: 20-0810NH_CTDBTL_001-073IU.csv
Reading 175/361: 20-0901NH_CTDBTL_001-075ID.csv
Reading 176/361: 20-0901NH_CTDBTL_001-075IU.csv
Reading 177/361: 20-0903JD_CTDBTL_001-071ID.csv
Reading 178/361: 20-0903JD_CTDBTL_001-071IU.csv
Reading 179/361: 20-0907M2_CTDBTL_001-961ID.csv
Reading 180/361: 20-0907M2_CTDBTL_001-961IU.csv
Reading 181/361: 20-0911NH_CTDBTL_001-075ID.csv
Reading 182/361: 20-0911NH_CTDBTL_001-075IU.csv
Reading 183/361: 20-1001NH_CTDBTL_001-093D.csv
Reading 184/361: 20-1001NH_CTDBTL_001-093U.csv
Reading 185/361: 20-1004MF_CTDBTL_123-682ID.csv
Reading 186/361: 20-1004MF_CTDBTL_123-682IU.csv
Reading 187/361: 20-1008NH_CTDBTL_001-075ID.csv
Reading 188/361: 20-1008NH_CTDBTL_001-075IU.csv
Reading 189/361: 20-1011NH_CTDBTL_001-067ID.csv
Reading 190/361: 20-1011NH_CTDBTL_001-067IU.csv
Reading 191/361: 20-1101NH_CTDBTL_001-106D.csv
Reading 192/361: 20-1101NH_CTDBTL_001-106U.csv
Reading 193/361: 20-1104SH_CTDBTL_031-035ID.csv
Reading 194/361: 20-1104SH_CTDBTL_031-035IU.csv
Reading 195/361: 20-1104SH_CTDBTL_031-114ID.csv
Reading 196/361: 20-1104SH_CTDBTL_031-114IU.csv
Reading 197/361: 20-1104SH_CTDBTL_036-036ID.csv
Reading 198/361: 20-1104SH_CTDBTL_036-036IU.csv
Reading 199/361: 20-1104SH_CTDBTL_037-114ID.csv
Reading 200/361: 20-1104SH_CTDBTL_037-114IU.csv
Reading 201/361: 20-1108NH_CTDBTL_001-072ID.csv
Reading 202/361: 20-1108NH_CTDBTL_001-072IU.csv
Reading 203/361: 20-1110NH_CTDBTL_001-666ID.csv
Reading 204/361: 20-1110NH_CTDBTL_001-666IU.csv
Reading 205/361: 20-1202NH_CTDBTL_001-075D.csv
Reading 206/361: 20-1202NH_CTDBTL_001-075U.csv
Reading 207/361: 20-1203SH_CTDBTL_001-057D.csv
Reading 208/361: 20-1203SH_CTDBTL_001-057U.csv
Reading 209/361: 20-1207OS_CTDBTL_001-075D.csv
Reading 210/361: 20-1207OS_CTDBTL_001-075U.csv
Reading 211/361: 20-1210NH_CTDBTL_001-075D.csv
Reading 212/361: 20-1210NH_CTDBTL_001-075U.csv
Reading 213/361: 20-1301SH_CTDBTL_001-090D.csv
Reading 214/361: 20-1301SH_CTDBTL_001-090U.csv
Reading 215/361: 20-1304SH_CTDBTL_001-109D.csv
Reading 216/361: 20-1304SH_CTDBTL_001-109U.csv
Reading 217/361: 20-1307NH_CTDBTL_001-074D.csv
Reading 218/361: 20-1307NH_CTDBTL_001-074U.csv
Reading 219/361: 20-1311NH_CTDBTL_001-521D.csv
Reading 220/361: 20-1311NH_CTDBTL_001-521U.csv
Reading 221/361: 20-1402SH_CTDBTL_001-036D.csv
Reading 222/361: 20-1402SH_CTDBTL_001-036U.csv
Reading 223/361: 20-1404OS_CTDBTL_001-070D.csv
Reading 224/361: 20-1404OS_CTDBTL_001-070U.csv
Reading 225/361: 20-1407NH_CTDBTL_001-075D.csv
Reading 226/361: 20-1407NH_CTDBTL_001-075U.csv
Reading 227/361: 20-1411NH_CTDBTL_001-076D.csv
Reading 228/361: 20-1411NH_CTDBTL_001-076U.csv
Reading 229/361: 20-1501NH_CTDBTL_001-064D.csv
Reading 230/361: 20-1501NH_CTDBTL_001-064U.csv
Reading 231/361: 20-1501NH_CTDBTL_001-100D.csv
Reading 232/361: 20-1501NH_CTDBTL_001-100U.csv
Reading 233/361: 20-1501NH_CTDBTL_004-100D.csv
Reading 234/361: 20-1501NH_CTDBTL_004-100U.csv
Reading 235/361: 20-1504NH_CTDBTL_001-070D.csv
Reading 236/361: 20-1504NH_CTDBTL_001-070U.csv
Reading 237/361: 20-1507OC_CTDBTL_001-071D.csv
Reading 238/361: 20-1507OC_CTDBTL_001-071U.csv
Reading 239/361: 20-1511OC_CTDBTL_001-070D.csv
Reading 240/361: 20-1511OC_CTDBTL_001-070U.csv
Reading 241/361: 20-1601RL_CTDBTL_001-104D.csv
Reading 242/361: 20-1601RL_CTDBTL_001-104U.csv
Reading 243/361: 20-1604SH_CTDBTL_001-101D.csv
Reading 244/361: 20-1604SH_CTDBTL_001-101U.csv
Reading 245/361: 20-1607OS_CTDBTL_001-068D.csv
Reading 246/361: 20-1607OS_CTDBTL_001-068U.csv
Reading 247/361: 20-1611SR_CTDBTL_001-075D.csv
Reading 248/361: 20-1611SR_CTDBTL_001-075U.csv
Reading 249/361: 20-1701RL_CTDBTL_001-020D.csv
Reading 250/361: 20-1701RL_CTDBTL_001-020U.csv
Reading 251/361: 20-1701RL_CTDBTL_001-083D.csv
Reading 252/361: 20-1701RL_CTDBTL_001-083U.csv
Reading 253/361: 20-1701RL_CTDBTL_021-083D.csv
Reading 254/361: 20-1701RL_CTDBTL_021-083U.csv
Reading 255/361: 20-1704SH_CTDBTL_001-040D.csv
Reading 256/361: 20-1704SH_CTDBTL_001-040U.csv
Reading 257/361: 20-1704SH_CTDBTL_001-104D.csv
Reading 258/361: 20-1704SH_CTDBTL_001-104U.csv
Reading 259/361: 20-1704SH_CTDBTL_041-104D.csv
Reading 260/361: 20-1704SH_CTDBTL_041-104U.csv
Reading 261/361: 20-1708SR_CTDBTL_001-073D.csv
Reading 262/361: 20-1708SR_CTDBTL_001-073U.csv
Reading 263/361: 20-1711SR_CTDBTL_001-074D.csv
Reading 264/361: 20-1711SR_CTDBTL_001-074U.csv
Reading 265/361: 20-1802SH_CTDBTL_001-045D.csv
Reading 266/361: 20-1802SH_CTDBTL_001-045U.csv
Reading 267/361: 20-1804SH_CTDBTL_001-103D.csv
Reading 268/361: 20-1804SH_CTDBTL_001-103U.csv
Reading 269/361: 20-1806SR_CTDBTL_001-072D.csv
Reading 270/361: 20-1806SR_CTDBTL_001-072U.csv
Reading 271/361: 20-1810SR_CTDBTL_001-074D.csv
Reading 272/361: 20-1810SR_CTDBTL_001-074U.csv
Reading 273/361: 20-1902RL_CTDBTL_001-029D.csv
Reading 274/361: 20-1902RL_CTDBTL_001-029U.csv
Reading 275/361: 20-1904RL_CTDBTL_001-069D.csv
Reading 276/361: 20-1904RL_CTDBTL_001-069U.csv
Reading 277/361: 20-1907BH_CTDBTL_001-070D.csv
Reading 278/361: 20-1907BH_CTDBTL_001-070U.csv
Reading 279/361: 20-1911OC_CTDBTL_001-075D.csv
Reading 280/361: 20-1911OC_CTDBTL_001-075U.csv
Reading 281/361: 20-2001RL_CTDBTL_001-104D.csv
Reading 282/361: 20-2001RL_CTDBTL_001-104U.csv
Reading 283/361: 20-2007SR_CTDBTL_001-075D.csv
Reading 284/361: 20-2007SR_CTDBTL_001-075U.csv
Reading 285/361: 20-2010SR_CTDBTL_001-071D.csv
Reading 286/361: 20-2010SR_CTDBTL_001-071U.csv
Reading 287/361: 20-2101RL_CTDBTL_001-078D.csv
Reading 288/361: 20-2101RL_CTDBTL_001-078U.csv
Reading 289/361: 20-2105SH_CTDBTL_001-044D.csv
Reading 290/361: 20-2105SH_CTDBTL_001-044U.csv
Reading 291/361: 20-2107SR_CTDBTL_001-071D.csv
Reading 292/361: 20-2107SR_CTDBTL_001-071D.csv
Reading 293/361: 20-2107SR_CTDBTL_001-071U.csv
Reading 294/361: 20-2107SR_CTDBTL_001-071U.csv
Reading 295/361: 20-2111SR_CTDBTL_001-075D.csv
Reading 296/361: 20-2111SR_CTDBTL_001-075U.csv
Reading 297/361: 20-2204SH_CTDBTL_001-101D.csv
Reading 298/361: 20-2204SH_CTDBTL_001-101D.csv
Reading 299/361: 20-2204SH_CTDBTL_001-101U.csv
Reading 300/361: 20-2204SH_CTDBTL_001-101U.csv
Reading 301/361: 20-2208BH_CTDBTL_001-067D.csv
Reading 302/361: 20-2208BH_CTDBTL_001-067U.csv
Reading 303/361: 20-2211_CTDBTL_001-073D.csv
Reading 304/361: 20-2211_CTDBTL_001-073U.csv
Reading 305/361: 20-2301RL_CTDBTL_001-072D.csv
Reading 306/361: 20-2301RL_CTDBTL_001-072U.csv
Reading 307/361: 20-2304SH_CTDBTL_001-114D.csv
Reading 308/361: 20-2304SH_CTDBTL_001-114U.csv
Reading 309/361: 20-2307SR_CTDBTL_001-056D.csv
Reading 310/361: 20-2307SR_CTDBTL_001-056U.csv
Reading 311/361: 20-2307SR_CTDBTL_057-069D.csv
Reading 312/361: 20-2307SR_CTDBTL_057-069U.csv
Reading 313/361: 20-2311SR_CTDBTL_001-050D.csv
Reading 314/361: 20-2311SR_CTDBTL_001-050U.csv
Reading 315/361: 20-2311SR_CTDBTL_051-075D.csv
Reading 316/361: 20-2311SR_CTDBTL_051-075U.csv
Reading 317/361: 20-2401RL_CTDBTL_001-016D.csv
Reading 318/361: 20-2401RL_CTDBTL_001-016D.csv
Reading 319/361: 20-2401RL_CTDBTL_001-016U.csv
Reading 320/361: 20-2401RL_CTDBTL_001-016U.csv
Reading 321/361: 20-2401RL_CTDBTL_017-033D.csv
Reading 322/361: 20-2401RL_CTDBTL_017-033D.csv
Reading 323/361: 20-2401RL_CTDBTL_017-033U.csv
Reading 324/361: 20-2401RL_CTDBTL_017-033U.csv
Reading 325/361: 20-2404SH_CTDBTL_001-116D.csv
Reading 326/361: 20-2404SH_CTDBTL_001-116U.csv
Reading 327/361: 20-2408SR_CTDBTL_001-075D.csv
Reading 328/361: 20-2408SR_CTDBTL_001-075D.csv
Reading 329/361: 20-2408SR_CTDBTL_001-075U.csv
Reading 330/361: 20-2408SR_CTDBTL_001-075U.csv
Reading 331/361: 20-2411SR_CTDBTL_001-048D.csv
Reading 332/361: 20-2411SR_CTDBTL_001-048U.csv
Reading 333/361: 20-2411SR_CTDBTL_001-048U.csv
Reading 334/361: 20-2501RL_CTDBTL_001-116D.csv
Reading 335/361: 20-2501RL_CTDBTL_001-116D.csv
Reading 336/361: 20-2501RL_CTDBTL_001-116U.csv
Reading 337/361: 20-2501RL_CTDBTL_001-116U.csv
Reading 338/361: 20-2502RL_CTDBTL_001-003D.csv
Reading 339/361: 20-2502RL_CTDBTL_001-003D.csv
Reading 340/361: 20-2502RL_CTDBTL_001-003U.csv
Reading 341/361: 20-2502RL_CTDBTL_001-003U.csv
Reading 342/361: 20-2504SH_CTDBTL_001-116D.csv
Reading 343/361: 20-2504SH_CTDBTL_001-116D.csv
Reading 344/361: 20-2504SH_CTDBTL_001-116U.csv
Reading 345/361: 20-2504SH_CTDBTL_001-116U.csv
Reading 346/361: 20-2507_CTD_001-055D.csv
Reading 347/361: 20-2507_CTD_001-055U.csv
Reading 348/361: 20-2511_CTD_001-047D.csv
Reading 349/361: 20-2511_CTD_001-047U.csv
Reading 350/361: 20-2601_CTD_001-116D.csv
Reading 351/361: 20-2601_CTD_001-116U.csv
Reading 352/361: 20-2604_CTD_001-112D.csv
Reading 353/361: 20-2604_CTD_001-112U.csv
Reading 354/361: 20-2607_CTD_001-072D.csv
Reading 355/361: 20-2607_CTD_001-072U.csv
Reading 356/361: 20-9804JD_CTDBTL_001-050D.csv
Reading 357/361: 20-9804JD_CTDBTL_001-050U.csv
Reading 358/361: 20-9804JD_CTDBTL_051-085D.csv
Reading 359/361: 20-9804JD_CTDBTL_051-085U.csv
Reading 360/361: 20-9807NH_CTDBTL_001-070D.csv
Reading 361/361: 20-9807NH_CTDBTL_001-070U.csv
Code
d_csv |>
  arrange(cruise_key, file_csv) |>
  select(cruise_key, file_csv, nrows) |>
  dt(
    caption = "Number of rows read per file",
    fname = "ctd_rows_per_file"
  ) |>
  formatCurrency("nrows", currency = "", digits = 0, mark = ",")
Code
# A file that reads zero rows is a failure, not an observation — and it is the
# one failure this pipeline could not see.
#
# `dir_dl` sits inside Google Drive, which evicts a synced file to a cloud-only
# placeholder. `list.files()` still reports the full size (12,254,953 bytes for
# 19-9501JD_CTDBTL_001-646D.csv) and `ls -lO` marks it `dataless`, but reading it
# times out at the filesystem and `read_csv()` returns a **0-row tibble with no
# error**. On the v2026.08.08 run 20 files across 14 cruises read 0 rows exactly
# this way, and the only copy holding data was the `… 2.csv` conflict copy Drive
# had made beside each one. Nothing downstream distinguishes "this cruise has no
# downcast" from "the downcast file was empty".
d_csv_empty <- d_csv |> filter(nrows == 0)

if (nrow(d_csv_empty) > 0)
  stop(glue(
    "{nrow(d_csv_empty)} CSV(s) read 0 rows:\n",
    paste0("  ", d_csv_empty$path_unzip, collapse = "\n"), "\n",
    "A CTD db CSV is never legitimately empty. Check for a Google Drive ",
    "cloud-only placeholder (`ls -lO` reports `dataless`) and materialize it ",
    "with `cat file > /dev/null`, or move the download directory off Drive."))

10 Detect and Correct Column Type Mismatches

Code
d_csv <- d_csv |>
  mutate(
    col_empties = map(data, \(x) {
      tibble(
        col_name = names(x),
        n_empty = map_int(x, \(col) sum(is.na(col)))
      ) |>
        filter(n_empty == nrow(x)) |>
        pull(col_name)
    }),
    col_types = map2(data, col_empties, \(x, y) {
      tibble(
        col_name = names(x),
        col_type = map_chr(x, \(col) class(col)[1])
      ) |>
        filter(!col_name %in% y)
    })
  )

# find most common type for each column across all files
d_types <- d_csv |>
  select(path, col_types) |>
  unnest(col_types) |>
  count(col_name, col_type) |>
  group_by(col_name) |>
  slice_max(n, n = 1, with_ties = FALSE) |>
  ungroup() |>
  select(col_name, expected_type = col_type)

d_mismatches <- d_csv |>
  select(cruise_key, path, col_types) |>
  unnest(col_types) |>
  left_join(d_types, by = "col_name") |>
  filter(col_type != expected_type) |>
  arrange(col_name, path)

if (nrow(d_mismatches) > 0) {
  say("Type mismatches detected - converting columns...")
}
Type mismatches detected - converting columns...
Code
# bind data, converting mismatched columns to expected type
d_bind <- d_csv |>
  mutate(
    data = map2(data, path, \(x, p) {
      x_mismatches <- d_mismatches |>
        filter(path == p)

      if (nrow(x_mismatches) > 0) {
        for (i in 1:nrow(x_mismatches)) {
          col <- x_mismatches$col_name[i]
          expected <- x_mismatches$expected_type[i]
          na_before <- sum(is.na(x[[col]]))

          suppressWarnings({
            x[[col]] <- switch(
              expected,
              "numeric" = as.numeric(x[[col]]),
              "integer" = as.integer(x[[col]]),
              "logical" = as.logical(x[[col]]),
              "character" = as.character(x[[col]]),
              x[[col]]
            )
          })

          na_after <- sum(is.na(x[[col]]))
          na_generated <- na_after - na_before

          if (na_generated > 0) {
            say(glue(
              "  {basename(p)}: {col} ({x_mismatches$col_type[i]} -> {expected}) generated {na_generated} NAs"
            ))
          }

          d_mismatches[
            d_mismatches$path == p & d_mismatches$col_name == col,
            "nas_generated"
          ] <<- na_generated
        }
      }
      x
    })
  ) |>
  unnest(data)
  19-9504NH_CTDBTL_001-061D.csv: phaeo (character -> numeric) generated 1 NAs
  19-9504NH_CTDBTL_001-061ID.csv: phaeo (character -> numeric) generated 1 NAs
  19-9504NH_CTDBTL_001-061IU.csv: phaeo (character -> numeric) generated 1 NAs
  19-9504NH_CTDBTL_001-061U.csv: phaeo (character -> numeric) generated 1 NAs
  19-9510NH_CTDBTL_001-066D.csv: ox_b (character -> numeric) generated 1 NAs
  19-9510NH_CTDBTL_001-066ID.csv: ox_b (character -> numeric) generated 1 NAs
  19-9510NH_CTDBTL_001-066IU.csv: ox_b (character -> numeric) generated 1 NAs
  19-9510NH_CTDBTL_001-066U.csv: ox_b (character -> numeric) generated 1 NAs
  19-9707JD_CTDBTL_001-066D.csv: ox_b (character -> numeric) generated 2 NAs
  19-9707JD_CTDBTL_001-066U.csv: ox_b (character -> numeric) generated 2 NAs
  19-9802JD_CTDBTL_001-066D.csv: phaeo (character -> numeric) generated 1 NAs
  19-9802JD_CTDBTL_001-066U.csv: phaeo (character -> numeric) generated 1 NAs
  20-0010NH_CTDBTL_001-074ID.csv: po_t2 (character -> numeric) generated 21 NAs
  20-0107NH_CTDBTL_001-066D.csv: phaeo (character -> numeric) generated 1 NAs
  20-0107NH_CTDBTL_001-066U.csv: phaeo (character -> numeric) generated 1 NAs
  20-0107NH_CTDBTL_013-066ID.csv: phaeo (character -> numeric) generated 1 NAs
  20-0107NH_CTDBTL_013-066IU.csv: phaeo (character -> numeric) generated 1 NAs
  20-0110NH_CTDBTL_001-066D.csv: phaeo (character -> numeric) generated 2 NAs
  20-0110NH_CTDBTL_001-066U.csv: phaeo (character -> numeric) generated 2 NAs
  20-0407JD_CTDBTL_013-074ID.csv: phaeo (character -> numeric) generated 1 NAs
  20-0407JD_CTDBTL_013-074IU.csv: phaeo (character -> numeric) generated 1 NAs
  20-0407JD_CTDBTL_040-074ID.csv: phaeo (character -> numeric) generated 1 NAs
  20-0407JD_CTDBTL_040-074IU.csv: phaeo (character -> numeric) generated 1 NAs
  20-0504NH_CTDBTL_001-075D.csv: phaeo (character -> numeric) generated 1 NAs
  20-0504NH_CTDBTL_001-075U.csv: phaeo (character -> numeric) generated 1 NAs
  20-0507NH_CTDBTL_001-075D.csv: phaeo (character -> numeric) generated 3 NAs
  20-0507NH_CTDBTL_001-075U.csv: phaeo (character -> numeric) generated 3 NAs
  20-0511NH_CTDBTL_001-072D.csv: phaeo (character -> numeric) generated 1 NAs
  20-0511NH_CTDBTL_001-072U.csv: phaeo (character -> numeric) generated 1 NAs
  20-1210NH_CTDBTL_001-075D.csv: ox_bu_m (character -> numeric) generated 191 NAs
  20-1210NH_CTDBTL_001-075U.csv: ox_bu_m (character -> numeric) generated 192 NAs
Code
if (nrow(d_mismatches) > 0) {
  d_mismatches |>
    group_by(col_name, expected_type, col_type) |>
    summarize(
      n_files = n(),
      total_nas = sum(nas_generated, na.rm = TRUE),
      files = paste(basename(path), collapse = "; "),
      .groups = "drop"
    ) |>
    arrange(desc(total_nas)) |>
    dt(
      caption = "Type mismatches by column",
      fname = "ctd_type_mismatches_by_column"
    ) |>
    formatCurrency(
      c("n_files", "total_nas"),
      currency = "",
      digits = 0,
      mark = ","
    )
}
Code
# reconcile cruise_key from study where mismatched
d_bind <- d_bind |>
  mutate(
    cruise_key = if_else(
      !is.na(study) &
        cruise_key != study &
        study != "Study",
      study,
      cruise_key
    ),
    `_source_file` = path_unzip
  ) |>
  relocate(`_source_file`, .after = cruise_key) |>
  select(
    -path_unzip,
    -path,
    -file_csv,
    -dir_unzip,
    -priority,
    -project,
    -study,
    -nrows,
    -col_empties,
    -col_types
  )

# fill missing ord_occ from cast_id
d_bind <- d_bind |>
  mutate(
    ord_occ = if_else(
      is.na(ord_occ) & !is.na(cast_id),
      str_extract(cast_id, "_(\\d{3})", group = 1),
      ord_occ
    )
  )

# normalize raw coordinate column names (CalCOFI CTD CSV ships Lon_Dec/Lat_Dec ->
# clean_names lon_dec/lat_dec) to the canonical dictionary names used downstream
d_bind <- d_bind |>
  rename(longitude = lon_dec, latitude = lat_dec)

# save intermediate for resumability
write_rds(d_bind, glue("{dir_tmp}/d_bind_pre_datetime.rds"), compress = "gz")

11 Format Date-Time Column

Code
d_bind <- read_rds(glue("{dir_tmp}/d_bind_pre_datetime.rds"))

d_bind <- d_bind |>
  mutate(
    date_time_utc = trimws(date_time_utc),
    date_time_format = case_when(
      str_detect(
        date_time_utc,
        "^\\d{1,2}-[A-z]{3}-\\d{4} \\d{1,2}:\\d{1,2}:\\d{1,2}$"
      ) ~ "dmy_hms",
      str_detect(
        date_time_utc,
        "^\\d{1,2}/\\d{1,2}/\\d{4} \\d{1,2}:\\d{1,2}$"
      ) ~ "mdy_hm",
      TRUE ~ "unknown"
    ),
    datetime_start_utc = NA,
    datetime_start_utc = if_else(
      date_time_format == "dmy_hms",
      suppressWarnings(dmy_hms(date_time_utc, tz = "UTC")),
      datetime_start_utc
    ),
    datetime_start_utc = if_else(
      date_time_format == "mdy_hm",
      suppressWarnings(mdy_hm(date_time_utc, tz = "UTC")),
      datetime_start_utc
    )
  ) |>
  relocate(date_time_format, datetime_start_utc, .after = date_time_utc) |>
  arrange(datetime_start_utc, depth)

stopifnot(all(!is.na(d_bind$datetime_start_utc)))

write_rds(d_bind, glue("{dir_tmp}/d_bind_post_datetime.rds"), compress = "gz")

d_bind |>
  group_by(cruise_key, `_source_file`, date_time_format) |>
  summarize(
    n_records = n(),
    datetime_min = min(datetime_start_utc),
    datetime_max = max(datetime_start_utc),
    .groups = "drop"
  ) |>
  arrange(cruise_key, `_source_file`, date_time_format) |>
  pivot_wider(
    names_from = date_time_format,
    values_from = n_records,
    values_fill = NA_real_
  ) |>
  relocate(dmy_hms, mdy_hm, .after = cruise_key) |>
  dt(
    caption = "Date-Time formats detected by cruise_key and source_file",
    fname = "ctd_datetime_formats"
  ) |>
  formatDate(c("datetime_min", "datetime_max"), method = "toLocaleString")

12 Pseudo-NA Values and Fill Missing Coordinates

Code
d_bind <- read_rds(glue("{dir_tmp}/d_bind_post_datetime.rds"))

pseudoNA_values <- c(-9.99e-29, -99)

d_bind <- d_bind |>
  mutate(
    is_lnsta_pseudoNA = ifelse(
      as.numeric(line) == 0 & as.numeric(sta) == 0,
      TRUE,
      FALSE
    ),
    is_lon_pseudoNA = map_lgl(longitude, ~ some(pseudoNA_values, near, .x)),
    is_lat_pseudoNA = map_lgl(latitude, ~ some(pseudoNA_values, near, .x)),
    longitude = if_else(
      is_lon_pseudoNA | is_lat_pseudoNA,
      NA_real_,
      longitude
    ),
    latitude = if_else(
      is_lon_pseudoNA | is_lat_pseudoNA,
      NA_real_,
      latitude
    ),
    lon_lnst = if_else(
      !is.na(line) & !is.na(sta) & !is_lnsta_pseudoNA,
      as.numeric(sf_project(
        from = "+proj=calcofi",
        to = "+proj=longlat +datum=WGS84",
        pts = cbind(x = as.numeric(line), y = as.numeric(sta))
      )[, 1]),
      NA_real_
    ),
    lat_lnst = if_else(
      !is.na(line) & !is.na(sta) & !is_lnsta_pseudoNA,
      as.numeric(sf_project(
        from = "+proj=calcofi",
        to = "+proj=longlat +datum=WGS84",
        pts = cbind(x = as.numeric(line), y = as.numeric(sta))
      )[, 2]),
      NA_real_
    ),
    longitude = if_else(is.na(longitude), lon_lnst, longitude),
    latitude = if_else(is.na(latitude), lat_lnst, latitude)
  )

stopifnot(sum(is.na(d_bind$longitude)) == 0)
stopifnot(sum(is.na(d_bind$latitude)) == 0)

d_bind |>
  filter(is_lon_pseudoNA | is_lat_pseudoNA) |>
  group_by(cruise_key) |>
  summarize(
    n_lon_pseudoNA = sum(is_lon_pseudoNA),
    n_lat_pseudoNA = sum(is_lat_pseudoNA)
  ) |>
  dt(
    caption = glue(
      "Cruises with pseudo-NAs ({paste(pseudoNA_values, collapse = ',')}) ",
      "set to NA, then filled from line/station coordinates."
    ),
    fname = "ctd_cruises_pseudoNA_lonlat"
  ) |>
  formatCurrency(
    c("n_lon_pseudoNA", "n_lat_pseudoNA"),
    currency = "",
    digits = 0,
    mark = ","
  )

13 Distance Filtering

Code
max_dist_dec_lnst_km <- 10

# THIS USED TO MATERIALIZE EVERY SCAN AS AN sf OBJECT WITH TWO GEOMETRY COLUMNS.
# With 45 more cruises that stopped fitting: `pts` reached the machine's 24 GB
# ceiling and `filter_pts` died with "vector memory limit of 24.0 Gb reached"
# after the distance step alone had run for 74 minutes. Both symptoms came from
# doing point work at the wrong grain, so fix the grain rather than the limit —
# raising mem.maxVSize on a 24 GB machine only buys swapping, and DuckDB is
# already holding half of RAM.
#
# Three changes, none of which alters a single output value:
#
#  1. The nominal line/station coordinate takes ~200 distinct values in the whole
#     dataset — one per CalCOFI grid station — but `purrr::map2(st_point)` built
#     one sfg per SCAN, i.e. ~30M R-level closure calls to produce ~200 distinct
#     points. Build the lookup once and join it on.
#  2. Run per cruise. Every operation here is row-wise (distance by_element) or a
#     point-in-polygon against a static grid, so there is no cross-cruise
#     dependency and batching is exactly equivalent — it just bounds peak memory
#     to one cruise instead of the whole archive.
#  3. Return PLAIN TIBBLES. Nothing downstream wants geometry: `d_filt` already
#     did `st_drop_geometry() |> select(-geom_lnst)`, and the two map previews
#     below rebuild it from lon/lat on a ~100-point sample.

grid_ref <- calcofi4r::cc_grid |>
  rename(any_of(c(site_key = "sta_key"))) |>
  select(grid_site = site_key)

# ~200 rows: the distinct nominal station coordinates, as points, built once
lnst_ref <- d_bind |>
  distinct(lon_lnst, lat_lnst) |>
  filter(!is.na(lon_lnst), !is.na(lat_lnst)) |>
  mutate(
    geom_lnst = purrr::map2(lon_lnst, lat_lnst, \(x, y) st_point(c(x, y))) |>
      st_sfc(crs = 4326))

say(glue("Distance filter: {nrow(lnst_ref)} distinct station coordinates ",
         "across {format(nrow(d_bind), big.mark = ',')} scans"))
Distance filter: 213 distinct station coordinates across 9,646,676 scans
Code
# compute the three derived columns for one cruise, and only those columns — the
# batch results are bound and joined back, so d_bind is never duplicated
dist_one_cruise <- function(d) {
  s <- d |>
    select(.row_id, longitude, latitude, lon_lnst, lat_lnst) |>
    left_join(lnst_ref, by = c("lon_lnst", "lat_lnst")) |>
    st_as_sf(coords = c("longitude", "latitude"), remove = FALSE, crs = 4326)

  # a scan with no nominal coordinate gets NA distance, exactly as st_distance
  # against an empty geometry did before
  s$dist_dec_lnst_km <- rep(NA_real_, nrow(s))
  has <- !st_is_empty(s$geom_lnst) & !is.na(s$lon_lnst)
  if (any(has))
    s$dist_dec_lnst_km[has] <- st_distance(
      st_geometry(s)[has], s$geom_lnst[has], by_element = TRUE) |>
      units::set_units(km) |>
      units::drop_units()

  s$is_dist_dec_lnst_within_max <- s$dist_dec_lnst_km <= max_dist_dec_lnst_km
  s$geom_lnst <- NULL
  st_agr(s) <- "constant"

  s |>
    st_join(grid_ref, join = st_intersects) |>
    st_drop_geometry() |>
    select(.row_id, dist_dec_lnst_km, is_dist_dec_lnst_within_max, grid_site)
}

d_bind <- d_bind |> mutate(.row_id = row_number())

# The batch results are three columns plus an id — assign them into d_bind by
# column rather than joining. A `right_join` back would allocate a second copy of
# the whole ~30M-row frame, which on a 24 GB machine is the very thing that
# killed the previous run.
d_dist <- d_bind |>
  group_split(cruise_key) |>
  purrr::map(dist_one_cruise) |>
  bind_rows() |>
  arrange(.row_id)

stopifnot(
  "distance batches must cover every scan exactly once, in order" =
    identical(d_dist$.row_id, d_bind$.row_id))

d_bind$dist_dec_lnst_km            <- d_dist$dist_dec_lnst_km
d_bind$is_dist_dec_lnst_within_max <- d_dist$is_dist_dec_lnst_within_max
d_bind$grid_site                   <- d_dist$grid_site
rm(d_dist)

pts <- d_bind |> select(-.row_id)

13.1 View sample cruise with excess distance points

Code
badcr_id <- "1507OC"

# `pts` is a plain tibble now (see pts_distance_from_lnst) — geometry is rebuilt
# here, on the sampled subset this map actually draws, rather than being carried
# on all ~30M scans just so two previews can use it
as_pts_sf <- function(d) st_as_sf(
  d, coords = c("longitude", "latitude"), remove = FALSE, crs = 4326)

# the extent from the lon/lat COLUMNS. st_bbox() would have to build geometry for
# every row first, which is the allocation this chunk exists to avoid — and the
# bounding box of a point set is just four numbers.
pts_bbox <- function(d) st_bbox(
  c(xmin = min(d$longitude, na.rm = TRUE), ymin = min(d$latitude, na.rm = TRUE),
    xmax = max(d$longitude, na.rm = TRUE), ymax = max(d$latitude, na.rm = TRUE)),
  crs = st_crs(4326))

pts_badcr <- pts |>
  filter(cruise_key == badcr_id)

bb_badcr <- pts_badcr |> pts_bbox() |> st_as_sfc()

mapView(bb_badcr) +
  mapView(
    pts_badcr |>
      filter(is_dist_dec_lnst_within_max) |>
      slice_sample(n = 1000) |>
      bind_rows(
        pts_badcr |>
          filter(!is_dist_dec_lnst_within_max)
      ) |>
      as_pts_sf() |>
      select(
        cruise_key,
        datetime_start_utc,
        longitude,
        latitude,
        sta_id,
        line,
        sta,
        dist_dec_lnst_km,
        is_dist_dec_lnst_within_max
      ),
    layer.name = glue(
      "Cruise {badcr_id}<br>distance (km)<br>lon/lat to line/station"
    ),
    zcol = "dist_dec_lnst_km",
    cex = 5,
    alpha = 0.5
  )

13.2 Filter points

Code
d_pts_cruise_filt_smry <- pts |>
  group_by(cruise_key) |>
  filter(any(!is_dist_dec_lnst_within_max)) |>
  summarize(
    n_all = n(),
    n_outside_grid = sum(is.na(grid_site)),
    pct_outside_grid = n_outside_grid / n_all,
    n_gt_cutoff = sum(!is_dist_dec_lnst_within_max, na.rm = T),
    avg_dist_gt_cutoff_km = if_else(
      n_gt_cutoff > 0,
      mean(dist_dec_lnst_km[!is_dist_dec_lnst_within_max], na.rm = T),
      NA_real_
    ),
    pct_gt_cutoff = n_gt_cutoff / n_all,
    n_rm = sum(!is_dist_dec_lnst_within_max | is.na(grid_site), na.rm = T),
    pct_rm = n_rm / n_all,
    .groups = "drop"
  ) |>
  filter(n_rm > 0) |>
  arrange(desc(pct_rm))

pts_filt <- pts |>
  filter(
    is_dist_dec_lnst_within_max,
    !is.na(grid_site)
  )

d_pts_cruise_filt_smry |>
  dt(
    caption = glue(
      "Cruises with rows filtered: outside CalCOFI grid or exceeded ",
      "{max_dist_dec_lnst_km} km cutoff from line/station coordinates."
    ),
    escape = F,
    fname = "ctd_cruises_distance_from_lnst"
  ) |>
  formatCurrency(
    c(
      "n_all",
      "n_gt_cutoff",
      "avg_dist_gt_cutoff_km",
      "n_outside_grid",
      "n_rm"
    ),
    currency = "",
    digits = 0,
    mark = ","
  ) |>
  formatPercentage(
    c("pct_gt_cutoff", "pct_outside_grid", "pct_rm"),
    digits = 2
  )

13.3 View filtered points

Code
bb_cr <- pts_filt |> pts_bbox() |> st_as_sfc()

mapView(bb_cr) +
  mapView(
    pts_filt |>
      group_by(cruise_key) |>
      slice_sample(n = 100) |>
      ungroup() |>
      as_pts_sf() |>
      select(
        cruise_key,
        longitude,
        latitude,
        sta_id,
        line,
        sta,
        dist_dec_lnst_km,
        datetime_start_utc
      ),
    zcol = "cruise_key",
    cex = 5,
    alpha = 0.5
  )

13.4 Check for duplicates by datetime and depth

Code
d_dupes <- pts_filt |>
  select(datetime_start_utc, depth) |>
  group_by(datetime_start_utc, depth) |>
  summarize(n = n(), .groups = "drop") |>
  filter(n > 1) |>
  arrange(desc(n))

n_distinct_dtime_depth <- pts_filt |>
  select(datetime_start_utc, depth) |>
  n_distinct()

d_dupes |>
  slice(c(1:10, (n() - 9):n())) |>
  dt(
    caption = glue(
      "First and last 10 duplicates by datetime_start_utc, depth ",
      "(nrows = {format(nrow(d_dupes), big.mark = ',')}; ",
      "n_distinct = {format(n_distinct_dtime_depth, big.mark = ',')})"
    ),
    fname = "ctd_duplicates_by_datetime_depth"
  )

14 Rename Fields

Apply field renames from flds_redefine.csv.

Code
# apply column renames from flds_redefine
renames <- d_flds_rd |>
  filter(fld_old != fld_new) |>
  select(fld_old, fld_new)

# `pts_filt` is already a plain tibble and geom_lnst no longer exists — the
# distance chunk drops both rather than carrying geometry across ~30M scans
d_filt <- pts_filt

for (i in seq_len(nrow(renames))) {
  old <- renames$fld_old[i]
  new <- renames$fld_new[i]
  if (old %in% names(d_filt)) {
    d_filt <- d_filt |> rename(!!new := !!old)
  }
}

# drop intermediate columns
d_filt <- d_filt |>
  select(
    -any_of(c(
      "date_time_pst",
      "date_time_utc",
      "date_time_format",
      "is_lon_pseudoNA",
      "is_lat_pseudoNA",
      "is_lnsta_pseudoNA",
      "lon_lnst",
      "lat_lnst",
      "dist_dec_lnst_km",
      "is_dist_dec_lnst_within_max",
      "grid_site"
    ))
  )

# write raw table for pivoting
dbWriteTable(con, "ctd_raw", d_filt, overwrite = TRUE)
say(glue("Loaded {nrow(d_filt)} rows into ctd_raw"))
Loaded 9520581 rows into ctd_raw

15 De-duplicate ctd_raw

Some cruises ship the same casts twice in the download archive — a primary copy plus a reorganized copy in a sub-folder (e.g. db_csvs/ alongside db_csvs/south-north/), both classified final by the path regex, so both pass the priority filter (see the duplicates reported above). The copies are identical except for re-rounded coordinates (and occasionally a re-processed measurement value). Collapse to one row per measurement position so the deterministic UUIDs downstream (ctd_cast_uuid, ctd_measurement_uuid, …) are unique.

Code
raw_cols <- dbGetQuery(
  con,
  "SELECT column_name FROM information_schema.columns WHERE table_name = 'ctd_raw'"
)$column_name

# a measurement position is identified by these five columns; duplicate copies
# share all five and differ only in re-rounded coordinates / re-processed values
dedup_key  <- c("cruise_key", "cast_key", "cast_dir", "datetime_start_utc", "depth_m")
stopifnot(
  "ctd_raw missing expected columns for de-duplication" =
    all(c(dedup_key, "_source_file", "latitude", "longitude") %in% raw_cols))

# keep the primary copy: reorganized duplicates sit in deeper source paths, so
# prefer the shortest `_source_file`; coordinates tie-break for full determinism
n_before <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_raw")$n
dbExecute(con, glue("
  CREATE OR REPLACE TABLE ctd_raw AS
  SELECT * FROM ctd_raw
  QUALIFY ROW_NUMBER() OVER (
    PARTITION BY {paste(dedup_key, collapse = ', ')}
    ORDER BY length(\"_source_file\"), \"_source_file\", latitude, longitude) = 1"))
[1] 7971569
Code
n_after <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_raw")$n

cat(glue(
  "ctd_raw de-duplicated: removed {format(n_before - n_after, big.mark = ',')} ",
  "duplicate-position rows ",
  "({round(100 * (n_before - n_after) / max(n_before, 1), 1)}%); ",
  "{format(n_after, big.mark = ',')} rows remain"), "\n")
ctd_raw de-duplicated: removed 1,549,012 duplicate-position rows (16.3%); 7,971,569 rows remain 
Code
# must always evaluate: line ~217 may have set eval=FALSE to skip read+bind
# when restoring from checkpoint; this chunk re-enables computation through
# write_parquet. without `eval: true` it would inherit eval=FALSE and be
# skipped, so write_parquet never runs yet downstream chunks expect its output.
if (parquet_complete) {
  knitr::opts_chunk$set(eval = FALSE)
  say("Parquet complete — skipping to upload")
} else {
  knitr::opts_chunk$set(eval = TRUE)
}

16 Cross-Dataset Bridge

Load reference tables (ship, cruise, grid) from the ichthyo ingest, derive ship_key, and validate against known ships/cruises.

Code
# tables declared in calcofi.modifies — loaded as TABLEs (writable)
modifies_tables <- c("ship")  # from calcofi.modifies in YAML frontmatter
load_prior_tables(
  con         = con,
  tables      = modifies_tables,
  parquet_dir = cc_stage_path("parquet", "swfsc_ichthyo")
)
# A tibble: 1 × 3
  table  rows has_geom
  <chr> <dbl> <lgl>   
1 ship     48 FALSE   
Code
# cruise + grid as VIEWs (read-only: cruise used for JOIN, grid for assign_grid_key)
load_prior_tables(
  con         = con,
  tables      = c("cruise", "grid"),
  parquet_dir = cc_stage_path("parquet", "swfsc_ichthyo"),
  as_view     = TRUE
)
# A tibble: 2 × 3
  table   rows has_geom
  <chr>  <dbl> <lgl>   
1 cruise   691 FALSE   
2 grid     218 TRUE    
Code
# snapshot PKs of modified tables before modifications
modifies_pks <- list()
for (tbl in modifies_tables) {
  pk_col <- dbGetQuery(con, glue(
    "SELECT column_name FROM information_schema.columns
     WHERE table_name = '{tbl}' ORDER BY ordinal_position LIMIT 1"))$column_name
  modifies_pks[[tbl]] <- list(
    pk_col = pk_col,
    keys   = dbGetQuery(con, glue("SELECT {pk_col} FROM {tbl}"))[[1]])
}

# apply cruise_key corrections for known study-column data quality errors.
# runs unconditionally so it fires for both fresh runs (YYMMKK keys) and
# checkpoint-restored runs (already-converted YYYY-MM-NODC keys).
for (i in seq_len(nrow(d_cruise_corrections))) {
  n_wrong <- dbGetQuery(con, glue(
    "SELECT COUNT(*) AS n FROM ctd_raw
     WHERE cruise_key = '{d_cruise_corrections$cruise_key_raw[i]}'"))$n
  if (n_wrong > 0) {
    dbExecute(con, glue(
      "UPDATE ctd_raw SET cruise_key = '{d_cruise_corrections$cruise_key_correct[i]}'
       WHERE cruise_key = '{d_cruise_corrections$cruise_key_raw[i]}'"))
    say(glue(
      "cruise_key correction: {d_cruise_corrections$cruise_key_raw[i]} → ",
      "{d_cruise_corrections$cruise_key_correct[i]} ({format(n_wrong, big.mark=',')} rows)"))
  }
}
cruise_key correction: 2511ZZ → 2511SR (28,383 rows)
Code
# detect whether cruise_key already in YYYY-MM-NODC format (from checkpoint)
sample_ck <- dbGetQuery(
  con, "SELECT cruise_key FROM ctd_raw LIMIT 1")$cruise_key
cruise_key_already_converted <- grepl("^\\d{4}-\\d{2}-", sample_ck)

if (!cruise_key_already_converted) {
  # 0404NHJD is a combined New Horizon + Jordan Davis cruise; use primary ship NH
  dbExecute(
    con,
    "UPDATE ctd_raw SET cruise_key = LEFT(cruise_key, 6)
     WHERE LENGTH(cruise_key) > 6"
  )

  # derive ship_key from last 2 chars of cruise_key (YYMMKK format from filename)
  dbExecute(con, "ALTER TABLE ctd_raw ADD COLUMN IF NOT EXISTS ship_key VARCHAR")
  dbExecute(con, "UPDATE ctd_raw SET ship_key = RIGHT(cruise_key, 2)")

  # validate ship_key against ship reference table
  ctd_ships <- dbGetQuery(con, "SELECT DISTINCT ship_key FROM ctd_raw")
  ref_ships <- dbGetQuery(con, "SELECT ship_key FROM ship")
  orphan_ships <- setdiff(ctd_ships$ship_key, ref_ships$ship_key)

  if (length(orphan_ships) > 0) {
    say(glue(
      "{length(orphan_ships)} ship_key(s) in CTD not in ship table: ",
      "{paste(orphan_ships, collapse = ', ')}"
    ))

    # run match_ships() with centralized renames for orphan ships
    orphan_tbl <- dbGetQuery(con, glue(
      "SELECT DISTINCT ship_key AS ship_code, NULL AS ship_name
       FROM ctd_raw
       WHERE ship_key IN ({paste(shQuote(orphan_ships, type = 'sh'), collapse = ', ')})")) |>
      mutate(ship_name = as.character(ship_name))

    ship_result <- match_ships(
      unmatched_ships  = orphan_tbl,
      reference_ships  = dbReadTable(con, "ship"),
      ship_renames_csv = here("metadata/ship_renames.csv"),
      fetch_ices       = FALSE)

    # insert interim entries for still-unmatched ships (ship_nodc = "?XX?")
    ensure_interim_ships(con, ship_result)

    dbGetQuery(
      con,
      glue(
        "SELECT ship_key, COUNT(DISTINCT cruise_key) AS n_cruises,
                COUNT(*) AS n_rows
         FROM ctd_raw
         WHERE ship_key IN ({paste(shQuote(orphan_ships, type = 'sh'), collapse = ', ')})
         GROUP BY ship_key
         ORDER BY ship_key"
      )
    ) |>
      dt(
        caption = "Orphan ship_keys (in CTD but not in ship table)",
        fname = "ctd_orphan_ship_keys"
      )
  } else {
    say("All CTD ship_keys found in ship reference table")
  }

  # convert cruise_key from YYMMKK → YYYY-MM-NODC format
  convert_cruise_key_format(con, "ctd_raw", old_key_col = "cruise_key")
  dbExecute(con, "ALTER TABLE ctd_raw DROP COLUMN cruise_key")
  dbExecute(con, "ALTER TABLE ctd_raw RENAME COLUMN cruise_key_new TO cruise_key")
} else {
  say("cruise_key already in YYYY-MM-NODC format (from checkpoint)")
}
All CTD ship_keys found in ship reference table
[1] 0
Code
# validate cruise_key against cruise reference table
ctd_cruises <- dbGetQuery(con, "SELECT DISTINCT cruise_key FROM ctd_raw")
ref_cruises <- dbGetQuery(con, "SELECT cruise_key FROM cruise")
orphan_cruises <- setdiff(ctd_cruises$cruise_key, ref_cruises$cruise_key)

if (length(orphan_cruises) > 0) {
  say(glue(
    "{length(orphan_cruises)} cruise_key(s) in CTD not in cruise table: ",
    "{paste(head(orphan_cruises, 10), collapse = ', ')}",
    "{if (length(orphan_cruises) > 10) '...' else ''}"
  ))
  dbGetQuery(
    con,
    glue(
      "SELECT cruise_key, COUNT(*) AS n_rows
       FROM ctd_raw
       WHERE cruise_key IN ({paste(shQuote(orphan_cruises, type = 'sh'), collapse = ', ')})
       GROUP BY cruise_key
       ORDER BY cruise_key"
    )
  ) |>
    dt(
      caption = "Orphan cruise_keys (in CTD but not in cruise table)",
      fname = "ctd_orphan_cruise_keys"
    )
} else {
  say("All CTD cruise_keys found in cruise reference table")
}
25 cruise_key(s) in CTD not in cruise table: 2020-07-33P4, 2020-10-33P4, 2025-02-33UD, 2018-10-33P4, 2023-07-33P4, 2024-11-33P4, 2016-11-33P4, 2024-01-33UD, 2026-07-3322, 2021-07-33P4...

17 Save Checkpoint

Save a DuckDB checkpoint after the expensive read+bind+filter+bridge steps so subsequent runs can resume from here.

Code
if (!file_exists(db_checkpoint) || overwrite) {
  close_duckdb(con)
  file_copy(db_path, db_checkpoint, overwrite = TRUE)
  con <- get_duckdb_con(db_path)
  load_duckdb_extension(con, "spatial")
  load_duckdb_extension(con, "icu")
  say(glue("Saved checkpoint: {db_checkpoint}"))
} else {
  say(glue("Checkpoint already exists: {db_checkpoint}"))
}
Saved checkpoint: /Users/bbest/Github/CalCOFI/workflows/data/wrangling/calcofi_ctd-cast_checkpoint.duckdb

18 Measurement Column Registry

Resolve which ctd_raw columns are measurements and which are their quality flags, from the shared metadata/measurement_type.csv registry. This split drives everything downstream: measurement columns pivot into ctd_measurement, and whatever is left over is by definition cast-level and becomes ctd_cast. Getting it wrong is how a per-scan column once leaked into ctd_cast and broke its primary key (workflows#53).

Notectd_wide was retired here

This section used to also build ctd_wide, a ~1 GB wide-format copy of ctd_raw for ERDDAP. It has no consumer: ERDDAP now serves CTD through EDDTableFromDatabase over DuckDB views plus the netCDF files built below, and the wide file’s whole-file heap read is what OOM’d ERDDAP in the first place (see the serving benchmark). The column-registry logic it carried is load-bearing, so it stays; the table itself is gone.

Code
# identify measurement and quality columns from measurement_type metadata
d_meas_ctd <- d_meas_type |>
  filter(str_detect(`_source_datasets`, "calcofi_ctd-cast"))
meas_cols <- d_meas_ctd$`_source_column`
qual_cols <- d_meas_ctd$`_qual_column` |> na.omit() |> as.character()
qual_cols <- qual_cols[qual_cols != ""]

raw_cols <- dbGetQuery(
  con,
  "SELECT column_name FROM information_schema.columns WHERE table_name = 'ctd_raw'"
)$column_name

# DIAGNOSTIC: a measurement column in the registry that is absent from ctd_raw is
# a silent no-op in the pivot; a numeric ctd_raw column that is NOT registered
# falls through to cast_cols and can break the ctd_cast PK. Report both.
meas_missing <- setdiff(meas_cols, raw_cols)
cast_leftover <- setdiff(raw_cols, c(meas_cols, qual_cols, "depth_m", "_source_file"))

cat(glue("registered measurement types : {nrow(d_meas_ctd)}\n"))
registered measurement types : 54
Code
cat(glue("  canonical (-> ctd_thin)    : {sum(d_meas_ctd$is_canonical, na.rm = TRUE)}\n"))
canonical (-> ctd_thin)    : 33
Code
cat(glue("  quality-flag columns       : {length(qual_cols)}\n"))
quality-flag columns       : 15
Code
cat(glue("registry columns absent from ctd_raw: {length(meas_missing)}",
         if (length(meas_missing)) glue(" -> {paste(meas_missing, collapse = ', ')}") else "", "\n"))
registry columns absent from ctd_raw: 0
Code
cat(glue("cast-level columns (-> ctd_cast)    : {length(cast_leftover)}\n"))
cast-level columns (-> ctd_cast)    : 14
Code
# the canonical set is what ctd_thin and the thinned netCDF carry, so show it
# explicitly rather than leaving it implicit in a flag column
d_meas_ctd |>
  select(measurement_type, units, is_canonical, source_column = `_source_column`) |>
  arrange(desc(is_canonical), measurement_type) |>
  datatable(
    rownames  = FALSE,
    caption   = "CTD measurement registry — is_canonical drives ctd_thin membership",
    options   = list(pageLength = 10, scrollX = TRUE, dom = "ftip"))

19 Split into Tidy Tables

19.1 ctd_cast — cast-level metadata

Code
# d_meas_ctd, meas_cols, qual_cols come from the Measurement Column Registry above

# use ctd_raw columns (includes ship_key and data_stage added via SQL)
raw_cols <- dbGetQuery(
  con,
  "SELECT column_name FROM information_schema.columns WHERE table_name = 'ctd_raw'"
)$column_name

cast_cols <- setdiff(
  raw_cols,
  c(meas_cols, qual_cols, "depth_m", "_source_file")
)

# extract one row per cast, keyed (cruise_key, cast_key, cast_dir,
# datetime_start_utc). within that key the only cast-level column that varies is
# lat/lon (sub-meter GPS jitter between same-second scans), so order by it for a
# deterministic pick. measurement-like columns (e.g. ox_aveu_m_sta_corr) must be
# registered in measurement_type.csv so they pivot to ctd_measurement and never
# reach cast_cols — otherwise SELECT DISTINCT would emit multiple rows per cast.
dbExecute(
  con,
  glue(
    "
  CREATE OR REPLACE TABLE ctd_cast AS
  SELECT {paste(cast_cols, collapse = ', ')}
  FROM ctd_raw
  QUALIFY ROW_NUMBER() OVER (
    PARTITION BY cruise_key, cast_key, cast_dir, datetime_start_utc
    ORDER BY latitude, longitude) = 1
  ORDER BY datetime_start_utc, cruise_key"
  )
)
[1] 7060753
Code
n_cast <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_cast")$n
say(glue("ctd_cast: {format(n_cast, big.mark = ',')} rows"))
ctd_cast: 7,060,753 rows
Code
# assign deterministic UUID from composite natural key (md5-based, DuckDB-native)
assign_deterministic_uuids_md5(
  con = con,
  table_name = "ctd_cast",
  id_col = "ctd_cast_uuid",
  key_cols = c("cruise_key", "cast_key", "cast_dir", "datetime_start_utc")
)

# guard: ctd_cast_uuid must be a valid unique PK — fail loudly if a
# measurement-like column ever leaks into cast_cols again (see #53)
stopifnot(
  "ctd_cast_uuid is not unique" =
    dbGetQuery(
      con,
      "SELECT COUNT(*) - COUNT(DISTINCT ctd_cast_uuid) AS n FROM ctd_cast"
    )$n == 0)

19.2 ctd_measurement — long-format sensor readings

Code
# add ctd_cast_uuid to ctd_raw for FK linkage
dbExecute(
  con,
  "
  ALTER TABLE ctd_raw ADD COLUMN IF NOT EXISTS ctd_cast_uuid VARCHAR"
)
[1] 0
Code
dbExecute(
  con,
  "
  UPDATE ctd_raw AS r
  SET ctd_cast_uuid = c.ctd_cast_uuid
  FROM ctd_cast AS c
  WHERE r.cruise_key   = c.cruise_key
    AND r.cast_key     = c.cast_key
    AND r.cast_dir     = c.cast_dir
    AND r.datetime_start_utc = c.datetime_start_utc"
)
[1] 7971569
Code
# --- recompute the two-sensor averages from sensors that are individually valid
#
# The source ships PRE-COMPUTED averages — TempAve, SaltAve_Corr, OxAve_StaCorr,
# OxAveuM_StaCorr — and their arithmetic is faithful. What it does not do,
# consistently, is check whether each sensor is worth averaging. Two failure
# modes, and the second is why validating the AVERAGE is not enough:
#
#  1. MISSING SENSOR. Only one sensor fitted, so the good one is averaged with
#     the -99 missing marker:
#       Depth 3.0 | Temp1 20.8951 | Temp2 -99.0    | TempAve -39.0525
#     Exact on 29,596 of 29,596 such rows in 9308NH. Lands far out of range, so
#     the bounds guard below would at least DELETE it — but deleting throws away
#     a perfectly good Temp1 reading to fix a bad average.
#
#  2. FAILED SENSOR. A sensor reading nonsense without being a sentinel — on
#     2607SH Temp2 ranges -31.95 to 32.64 degC while Temp1 sits at a steady 20.15:
#       Depth 3.0 | Temp1 20.1499 | Temp2 -18.607  | TempAve 0.7714
#     The mean is arithmetically correct and lands INSIDE the valid range, so
#     neither the -99 filter nor a bounds check on the average can see it. 407
#     such rows on that cruise alone — and a 0.77 degC "summer surface
#     temperature" produces an 18 degC cold anomaly, which is precisely the
#     quantity this data is being used to compute.
#
# THIS IS NOT A TEMPERATURE-ONLY PROBLEM, though temperature is the worst of it.
# Across a 14-cruise sample, of the rows where one sensor is out of bounds the
# source fell back to the good sensor 73,438 times for TempAve but took the naive
# mean 14,750 times; for oxygen the proportion inverts — OxAve_StaCorr took the
# naive mean on 348 of 457, OxAveuM_StaCorr on 245 of 252. Salinity is the well
# behaved one (5 of 105,403), so a temperature-only fix would have left oxygen
# wrong in the same way and looked complete.
#
# THE RULE: a sensor outside its declared bounds is ABSENT, and the average of
# what remains is the average — one good sensor averages to itself, no good
# sensor averages to NULL. That is `avg()`'s own NULL semantics, so it is
# written as list_avg() over the nulled-out sensors rather than a CASE ladder:
# the expression then IS the rule, and cannot drift from it.
#   https://duckdb.org/docs/stable/sql/functions/aggregates#avgarg
#
# Bounds come from the registry entry for the SENSOR series (temperature_1 etc.),
# so tightening them there tightens this too.
ave_repair <- tribble(
  ~ave_col,             ~s1_col,            ~s2_col,            ~bound_type,
  "temp_ave",           "temp1",            "temp2",            "temperature_1",
  "salt_ave_corr",      "salt1_corr",       "salt2_corr",       "salinity_1",
  "ox_ave_sta_corr",    "ox1_sta_corr",     "ox2_sta_corr",     "oxygen_ml_l_1",
  "ox_aveu_m_sta_corr", "ox1u_m_sta_corr",  "ox2u_m_sta_corr",  "oxygen_umol_kg_1")

raw_cols <- dbListFields(con, "ctd_raw")

ave_report <- pmap_dfr(ave_repair, function(ave_col, s1_col, s2_col, bound_type) {
  # a stage that does not ship a column (sensor-only cruises have no *_corr) is
  # skipped, not an error
  if (!all(c(ave_col, s1_col, s2_col) %in% raw_cols))
    return(tibble(measure = ave_col, n_repaired = NA_integer_,
                  n_still_oob = NA_integer_, note = "column absent"))

  lo <- d_meas_type$valid_min[d_meas_type$measurement_type == bound_type]
  hi <- d_meas_type$valid_max[d_meas_type$measurement_type == bound_type]
  stopifnot("sensor series must declare bounds for the average repair" =
              length(lo) == 1 && !is.na(lo) && !is.na(hi))

  # BETWEEN yields NULL on a NULL operand, so `NOT BETWEEN` is NULL — hence the
  # explicit IS NOT NULL on each arm: a genuinely absent sensor is not "bad".
  suspect <- glue(
    "({s1_col} IS NOT NULL AND {s1_col} NOT BETWEEN {lo} AND {hi})
     OR ({s2_col} IS NOT NULL AND {s2_col} NOT BETWEEN {lo} AND {hi})
     OR ({ave_col} IS NOT NULL AND {ave_col} NOT BETWEEN {lo} AND {hi})")

  n_bad <- dbGetQuery(con, glue(
    "SELECT COUNT(*) n FROM ctd_raw WHERE {suspect}"))$n

  dbExecute(con, glue("
    UPDATE ctd_raw SET {ave_col} = list_avg([
        CASE WHEN {s1_col} BETWEEN {lo} AND {hi} THEN {s1_col} END,
        CASE WHEN {s2_col} BETWEEN {lo} AND {hi} THEN {s2_col} END])
    WHERE {suspect}"))

  n_after <- dbGetQuery(con, glue(
    "SELECT COUNT(*) n FROM ctd_raw
     WHERE {ave_col} IS NOT NULL AND {ave_col} NOT BETWEEN {lo} AND {hi}"))$n

  tibble(measure = ave_col, n_repaired = n_bad, n_still_oob = n_after,
         note = glue("bounds {lo}–{hi} from {bound_type}"))
})

say(glue("two-sensor averages recomputed where a sensor was invalid: ",
         "{format(sum(ave_report$n_repaired, na.rm = TRUE), big.mark = ',')} row(s) ",
         "across {sum(!is.na(ave_report$n_repaired))} measure(s)"))
two-sensor averages recomputed where a sensor was invalid: 2,527,677 row(s) across 4 measure(s)
Code
stopifnot("average repair must leave no out-of-range average" =
            all(ave_report$n_still_oob == 0, na.rm = TRUE))

# pivot wide-to-long one measurement type at a time to avoid OOM
dbExecute(con, "DROP TABLE IF EXISTS ctd_measurement")
[1] 0
Code
for (i in seq_len(nrow(d_meas_ctd))) {
  row <- d_meas_ctd[i, ]
  src_col <- row$`_source_column`
  meas_type <- row$measurement_type
  qual_col <- row$`_qual_column`

  # a bare NULL is typed INT32 by DuckDB, so if the FIRST measurement type has no
  # qual column the CREATE TABLE below makes measurement_qual an integer column —
  # and the first later type that does have one fails inserting 'nan'. Type it.
  #
  # The source stores these codes in DOUBLE columns, so a plain cast yields "9.0"
  # rather than "9" and no longer matches the controlled vocabulary in
  # metadata/measurement_qual.csv (the CTD set: 0/1/2/8/9). Strip a trailing ".0" TEXTUALLY
  # rather than casting through INTEGER: a numeric cast would silently round an
  # unexpected "9.5" to 9, whereas this leaves anything non-integral verbatim so it
  # shows up in the audit below instead of being quietly normalized away.
  qual_expr <- if (!is.na(qual_col) && qual_col != "") {
    glue("regexp_replace(CAST({qual_col} AS VARCHAR), '\\.0+$', '')")
  } else {
    "CAST(NULL AS VARCHAR)"
  }

  sql_select <- glue(
    "
    SELECT ctd_cast_uuid, depth_m,
      '{meas_type}' AS measurement_type,
      CAST({src_col} AS DOUBLE) AS measurement_value,
      {qual_expr} AS measurement_qual
    FROM ctd_raw
    WHERE {src_col} IS NOT NULL
      AND NOT isnan(CAST({src_col} AS DOUBLE))
      AND isfinite(CAST({src_col} AS DOUBLE))
    "
  )

  if (i == 1) {
    dbExecute(con, glue("CREATE OR REPLACE TABLE ctd_measurement AS {sql_select}"))
  } else {
    dbExecute(con, glue("INSERT INTO ctd_measurement {sql_select}"))
  }
  say(glue("  {i}/{nrow(d_meas_ctd)}: {meas_type}"))
}
  1/54: beam_attenuation
  2/54: btl_ammonium
  3/54: btl_chlorophyll_a
  4/54: btl_depth
  5/54: btl_nitrate
  6/54: btl_nitrite
  7/54: btl_phaeopigment
  8/54: btl_phosphate
  9/54: btl_silicate
  10/54: btl_temperature
  11/54: dynamic_height
  12/54: est_chlorophyll_a_cruise_corr
  13/54: est_chlorophyll_a_sta_corr
  14/54: est_nitrate_cruise_corr
  15/54: est_nitrate_sta_corr
  16/54: fluorescence_v
  17/54: isus_v
  18/54: oxygen_btl_ml_l
  19/54: oxygen_btl_umol_kg
  20/54: oxygen_ml_l_1
  21/54: oxygen_ml_l_1_cruise_corr
  22/54: oxygen_ml_l_1_sta_corr
  23/54: oxygen_ml_l_2
  24/54: oxygen_ml_l_2_cruise_corr
  25/54: oxygen_ml_l_2_sta_corr
  26/54: oxygen_ml_l_ave_sta_corr
  27/54: oxygen_saturation_1
  28/54: oxygen_saturation_2
  29/54: oxygen_umol_kg_1
  30/54: oxygen_umol_kg_1_cruise_corr
  31/54: oxygen_umol_kg_1_sta_corr
  32/54: oxygen_umol_kg_2
  33/54: oxygen_umol_kg_2_cruise_corr
  34/54: oxygen_umol_kg_2_sta_corr
  35/54: oxygen_umol_kg_ave_sta_corr
  36/54: par
  37/54: ph
  38/54: potential_temperature_1
  39/54: potential_temperature_2
  40/54: pressure
  41/54: salinity_1
  42/54: salinity_1_corr
  43/54: salinity_2
  44/54: salinity_2_corr
  45/54: salinity_ave_corr
  46/54: salinity_btl
  47/54: sigma_theta_1
  48/54: sigma_theta_2
  49/54: spar
  50/54: specific_volume_anomaly
  51/54: temperature_1
  52/54: temperature_2
  53/54: temperature_ave
  54/54: transmissometer
Code
n_meas_raw <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_measurement")$n
say(glue("ctd_measurement (pre-sentinel): {format(n_meas_raw, big.mark = ',')} rows"))
ctd_measurement (pre-sentinel): 280,087,233 rows
Code
# --- strip the -99 missing-value sentinel from MEASUREMENTS -------------------
# -99 is this source's documented missing marker. The notebook already knows that:
# `pseudoNA_values <- c(-9.99e-29, -99)` in the fill_lonlat chunk strips it from
# longitude/latitude. It was never applied to the measurement columns, so -99 has
# been flowing through ctd_measurement -> ctd_thin -> obs as though it were a real
# reading — 84,302 rows in release v2026.07.17, including CANONICAL oxygen
# (oxygen_ml_l_ave_sta_corr 953, oxygen_umol_kg_ave_sta_corr 4,294) alongside
# isus_v 40,479, ph 31,493 and spar 6,189.
#
# The NOT isnan / isfinite guard in the pivot above cannot catch it: -99 is a
# perfectly finite double. A -99 mL/L oxygen silently corrupts any mean, minimum
# or anomaly a consumer computes, which is why this is a DELETE and not merely a
# report — a missing value has no business existing as a row in long format.
#
# Matched exactly (= -99, not a tolerance): every observed case is exactly -99.00,
# and an exact test cannot swallow a real reading that merely rounds near it. The
# two genuinely signed types (dynamic_height, specific_volume_anomaly) are
# reported separately below so a real -99 there would be visible rather than
# assumed away.
SENTINEL <- -99

sentinel_tally <- dbGetQuery(con, glue("
  SELECT measurement_type, COUNT(*) AS n,
         COUNT(DISTINCT ctd_cast_uuid) AS n_casts
  FROM ctd_measurement WHERE measurement_value = {SENTINEL}
  GROUP BY 1 ORDER BY n DESC"))

n_sentinel <- sum(sentinel_tally$n)
dbExecute(con, glue(
  "DELETE FROM ctd_measurement WHERE measurement_value = {SENTINEL}"))
[1] 20440201
Code
n_meas <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_measurement")$n
say(glue("-99 sentinel rows removed: {format(n_sentinel, big.mark = ',')} ",
         "across {nrow(sentinel_tally)} measurement type(s)"))
-99 sentinel rows removed: 20,440,201 across 34 measurement type(s)
Code
say(glue("ctd_measurement: {format(n_meas, big.mark = ',')} rows"))
ctd_measurement: 259,647,032 rows
Code
stopifnot("sentinel delete accounting" = n_meas_raw - n_sentinel == n_meas)

# --- enforce the registry's declared physical bounds -------------------------
# metadata/measurement_type.csv has carried valid_min/valid_max since the CTD
# registry was built, and NOTHING applied them — not here, not at release time.
# So v2026.08.07 shipped ~31k impossible values: ph down to -10, oxygen_ml_l_1 to
# -79.5, salinity_2 up to 46.3, sigma_theta_1 to -50.8, temperature_ave to -47.6.
#
# The -99 DELETE above only catches the sentinel ITSELF. It cannot catch a
# sentinel that has been scaled, offset or averaged upstream, and it says nothing
# about a genuinely wild reading. A declared bound is the general guard.
#
# This was ~40 lines of inline SQL that ran for this dataset alone. It is now
# calcofi4db::check_measurement_bounds() / drop_out_of_bounds(), which every
# ingest calls and release_database.qmd re-checks — because the same defect was
# live in calcofi_mets.sw_ph (494 values at -99, 16.6% of the type) with its
# bounds declared and equally unread.
bounds_pre <- check_measurement_bounds(
  con, "ctd_measurement", mt = d_meas_type)

# report BEFORE deleting: `undeclared` is a finding too, and the 9 CTD types with
# no bound are the ones still to take to the provider (spar spans -3.07e17..2.01e16)
bounds_datatable(bounds_pre)
Code
oob_tally <- drop_out_of_bounds(con, "ctd_measurement", mt = d_meas_type)

say(glue("out-of-range rows removed: {format(sum(oob_tally$n_bad), big.mark = ',')} ",
         "across {nrow(oob_tally)} type(s), against ",
         "{sum(bounds_pre$status != 'undeclared')} declared bound(s); ",
         "{sum(bounds_pre$status == 'undeclared')} type(s) still undeclared"))
out-of-range rows removed: 337,141 across 25 type(s), against 39 declared bound(s); 15 type(s) still undeclared
Code
# assign deterministic UUID (md5-based, DuckDB-native — single SQL, no R data transfer)
assign_deterministic_uuids_md5(
  con = con,
  table_name = "ctd_measurement",
  id_col = "ctd_measurement_uuid",
  key_cols = c("ctd_cast_uuid", "depth_m", "measurement_type")
)

# add cruise_key for partitioned parquet output
dbExecute(
  con,
  "ALTER TABLE ctd_measurement ADD COLUMN IF NOT EXISTS cruise_key VARCHAR"
)
[1] 0
Code
dbExecute(
  con,
  "UPDATE ctd_measurement AS m
   SET cruise_key = c.cruise_key
   FROM ctd_cast AS c
   WHERE m.ctd_cast_uuid = c.ctd_cast_uuid"
)
[1] 259309891
Code
# remove orphaned measurements with no parent cast
n_orphan <- dbGetQuery(
  con,
  "SELECT COUNT(*) FROM ctd_measurement WHERE ctd_cast_uuid IS NULL"
)[[1]]
if (n_orphan > 0) {
  dbExecute(con, "DELETE FROM ctd_measurement WHERE ctd_cast_uuid IS NULL")
  cat(glue("{format(n_orphan, big.mark = ',')} orphaned ctd_measurement ",
           "rows removed (NULL ctd_cast_uuid)"), "\n")
}

19.3 ctd_summary — summary stats across cast directions

Code
dbExecute(
  con,
  "
  CREATE OR REPLACE TABLE ctd_summary AS
  SELECT
    c.cruise_key,
    c.site_key,
    m.depth_m,
    m.measurement_type,
    AVG(m.measurement_value)    AS avg,
    CASE
      WHEN COUNT(*) = 1 THEN 0
      ELSE COALESCE(STDDEV_SAMP(m.measurement_value), 0)
    END                          AS stddev,
    COUNT(*)                     AS n_obs
  FROM ctd_measurement m
  INNER JOIN ctd_cast c ON m.ctd_cast_uuid = c.ctd_cast_uuid
  WHERE NOT isnan(m.measurement_value)
    AND isfinite(m.measurement_value)
  GROUP BY c.cruise_key, c.site_key, m.depth_m, m.measurement_type"
)
[1] 128405409
Code
n_summ <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_summary")$n
say(glue("ctd_summary: {format(n_summ, big.mark = ',')} rows"))
ctd_summary: 128,405,409 rows
Code
assign_deterministic_uuids_md5(
  con = con,
  table_name = "ctd_summary",
  id_col = "ctd_summary_uuid",
  key_cols = c("cruise_key", "site_key", "depth_m", "measurement_type")
)

19.4 measurement_type — reference table

Code
dbWriteTable(con, "measurement_type", d_meas_type, overwrite = TRUE)
say(glue("measurement_type: {nrow(d_meas_type)} rows"))
measurement_type: 200 rows
Code
# validate: all measurement_types used by this dataset are registered
ctd_types_used <- dbGetQuery(
  con,
  "SELECT DISTINCT measurement_type FROM ctd_measurement"
)$measurement_type

registered <- d_meas_type |>
  filter(str_detect(`_source_datasets`, "calcofi_ctd-cast")) |>
  pull(measurement_type)

unregistered <- setdiff(ctd_types_used, registered)
stopifnot(
  "all measurement_types used by calcofi_ctd-cast must be registered in _source_datasets" = length(
    unregistered
  ) ==
    0
)

19.5 Measurement provenance — instrument and accuracy eras

Which instrument produced a value, to what stated accuracy, and over which years. Recovered from the CalCOFI hydrographic master’s 0-Measurements table into metadata/calcofi/hydro-master/measurement_method.csv; the pipeline had no equivalent. It matters most where a method changed mid-record — water temperature switches from reversing thermometer (through 1993-04-15) to CTD thermistor (from 1993-08-11), which is the boundary this dataset begins at, and phosphate crosses four instruments from 1949.

The registry is one-to-many by design (Temperature has 6 eras), so it is joined by property rather than by exact type: CTD types carry sensor and correction suffixes (temperature_1, salinity_ave_corr, oxygen_ml_l_ave_sta_corr) that the era table knows nothing about, so those are stripped back to the base property before matching.

Code
path_method <- here("metadata/calcofi/hydro-master/measurement_method.csv")

d_method <- read_csv(path_method, show_col_types = F) |>
  filter(!is.na(measurement_type)) |>
  select(property = measurement_type, method, accuracy, year_started, year_ended)

# strip sensor/correction suffixes and the bottle marker to reach the base property:
#   temperature_ave  -> temperature ; oxygen_ml_l_ave_sta_corr -> oxygen_ml_l
#   btl_temperature  -> temperature ; salinity_btl            -> salinity
#   oxygen_btl_ml_l  -> oxygen_ml_l  (the marker is INFIX here, not a prefix/suffix —
#                                     the three positions are why this is not one regex)
base_property <- function(x) {
  x |>
    str_remove("^btl_") |>
    str_remove("_btl$") |>
    str_replace("_btl_", "_") |>
    str_remove("_(cruise|sta)_corr$") |>
    str_remove("_corr$") |>
    str_remove("_ave$") |>
    str_remove("_[12]$")
}

d_prov <- d_meas_ctd |>
  transmute(measurement_type, units, is_canonical,
            property = base_property(measurement_type)) |>
  inner_join(d_method, by = "property", relationship = "many-to-many") |>
  arrange(desc(is_canonical), measurement_type, year_started)

n_typ <- n_distinct(d_prov$measurement_type)
say(glue("CTD types with instrument provenance: {n_typ} of {nrow(d_meas_ctd)} ",
         "({n_distinct(d_prov$property)} distinct properties, ",
         "{nrow(d_prov)} type x era rows)"))
CTD types with instrument provenance: 24 of 54 (9 distinct properties, 37 type x era rows)
Code
say(glue("  unmatched (no era recorded): ",
         "{paste(setdiff(d_meas_ctd$measurement_type, d_prov$measurement_type) |> head(8), collapse = ', ')}",
         if (nrow(d_meas_ctd) - n_typ > 8) " ..." else ""))
  unmatched (no era recorded): beam_attenuation, btl_ammonium, btl_depth, dynamic_height, est_chlorophyll_a_cruise_corr, est_chlorophyll_a_sta_corr, est_nitrate_cruise_corr, est_nitrate_sta_corr ...
Code
d_prov |>
  transmute(measurement_type, canonical = is_canonical, units,
            method, accuracy, from = year_started, to = year_ended) |>
  datatable(
    caption  = paste("CTD measurement provenance — instrument, stated accuracy and",
                     "era, from the CalCOFI hydrographic master's 0-Measurements"),
    rownames = FALSE,
    options  = list(pageLength = 10, scrollX = TRUE, dom = "ftip"))
NoteDocumentation only, for now

This table is rendered, not written into metadata.json. Promoting it into the sidecar so consumers get instrument provenance alongside units is a follow-up that belongs in calcofi4db::build_metadata_json(), and 18 of the 35 era rows still need a data manager to confirm their mapping to canonical types (hydro_master_06).

19.6 Drop intermediate table

Code
dbExecute(con, "DROP TABLE IF EXISTS ctd_raw")
[1] 0

20 Measurement Frequency

ctd_measurement is by far the largest table in this dataset — in the prior release the calcofi_ctd-cast parquet totaled 22.16 GB, with ctd_measurement alone at 15.8 GB (233 M rows). Three stacked multipliers drive that size. The queries below quantify each, motivating the adaptively-thinned ctd_thin table built in the next section.

Code
freq_tbl_sizes <- dbGetQuery(con, "
  SELECT 'ctd_cast'         AS table_name, COUNT(*) AS n_rows FROM ctd_cast
  UNION ALL
  SELECT 'ctd_measurement' AS table_name, COUNT(*) AS n_rows FROM ctd_measurement
  UNION ALL
  SELECT 'ctd_summary'     AS table_name, COUNT(*) AS n_rows FROM ctd_summary")

n_meas_rows <- freq_tbl_sizes$n_rows[freq_tbl_sizes$table_name == "ctd_measurement"]
cat(glue("ctd_measurement: {format(n_meas_rows, big.mark = ',')} rows"), "\n")
ctd_measurement: 259,309,891 rows 
Code
freq_tbl_sizes |>
  dt(caption = "Normalized CTD table row counts (this run)",
     fname   = "ctd_freq_table_sizes")

20.1 Multiplier 1 — sub-meter depth resolution

CTD profiles record a sample roughly every metre from surface to seafloor (~3,600 m max). Thinning to a 10 m grid is therefore an order-of-magnitude reduction on the depth axis.

Code
# depth interval between consecutive samples within a cast, using `pressure`
# as a representative measurement_type (all types share a cast's depth grid)
freq_depth_int <- dbGetQuery(con, "
  WITH gaps AS (
    SELECT depth_m - LAG(depth_m) OVER (
             PARTITION BY ctd_cast_uuid ORDER BY depth_m) AS d_gap
    FROM ctd_measurement
    WHERE measurement_type = 'pressure')
  SELECT
    ROUND(MIN(d_gap), 2)                 AS min_m,
    ROUND(QUANTILE_CONT(d_gap, 0.25), 2) AS q25_m,
    ROUND(MEDIAN(d_gap), 2)              AS median_m,
    ROUND(QUANTILE_CONT(d_gap, 0.75), 2) AS q75_m,
    ROUND(MAX(d_gap), 2)                 AS max_m
  FROM gaps
  WHERE d_gap IS NOT NULL")

cat(glue(
  "median within-cast depth interval: {freq_depth_int$median_m} m ",
  "(~{round(10 / freq_depth_int$median_m)}x reduction at a 10 m grid)"), "\n")
median within-cast depth interval: 1 m (~10x reduction at a 10 m grid) 
Code
freq_depth_int |>
  dt(caption = "Within-cast depth interval (m), representative type `pressure`",
     fname   = "ctd_freq_depth_resolution")

20.2 Multiplier 2 — up- and down-casts both stored

Nearly every physical cast is recorded as a separate down-cast and up-cast. Keeping a single direction roughly halves the rows.

Code
# physical cast = (cruise_key, site_key, cast_base); direction resolved from
# cast_dir, falling back to the cast_key suffix (trailing d/u)
freq_cast_dir <- dbGetQuery(con, "
  WITH phys AS (
    SELECT DISTINCT
      cruise_key, site_key,
      regexp_replace(cast_key, '[duDU]$', '') AS cast_base,
      COALESCE(
        cast_dir,
        CASE
          WHEN RIGHT(cast_key, 1) IN ('d', 'D') THEN 'D'
          WHEN RIGHT(cast_key, 1) IN ('u', 'U') THEN 'U'
        END) AS dir
    FROM ctd_cast)
  SELECT
    COUNT(DISTINCT cruise_key || '|' || site_key || '|' || cast_base)
                                       AS n_physical_casts,
    COUNT(*) FILTER (WHERE dir = 'D')   AS n_with_downcast,
    COUNT(*) FILTER (WHERE dir = 'U')   AS n_with_upcast,
    COUNT(*) FILTER (WHERE dir IS NULL) AS n_unknown_dir
  FROM phys")

cat(glue(
  "{format(freq_cast_dir$n_physical_casts, big.mark = ',')} physical casts; ",
  "{format(freq_cast_dir$n_with_downcast, big.mark = ',')} have a downcast, ",
  "{format(freq_cast_dir$n_with_upcast, big.mark = ',')} an upcast"), "\n")
9,134 physical casts; 9,128 have a downcast, 9,131 an upcast 
Code
freq_cast_dir |>
  dt(caption = "Cast direction coverage per physical cast",
     fname   = "ctd_freq_cast_direction")

20.3 Multiplier 3 — redundant measurement-type variants

Many of the measurement types below are variants of the same property (multiple oxygen units and sensor/correction combinations, dual temperature and salinity sensors, derived estimates). ctd_thin keeps one canonical type per property — see the is_canonical flag in metadata/measurement_type.csv.

Code
freq_meas_types <- dbGetQuery(con, "
  SELECT
    measurement_type,
    COUNT(*)                      AS n_rows,
    COUNT(DISTINCT ctd_cast_uuid) AS n_casts
  FROM ctd_measurement
  GROUP BY measurement_type
  ORDER BY n_rows DESC")

cat(glue(
  "{nrow(freq_meas_types)} distinct measurement_type values in ctd_measurement"),
  "\n")
54 distinct measurement_type values in ctd_measurement 
Code
freq_meas_types |>
  dt(caption = "Rows per measurement_type",
     fname   = "ctd_freq_measurement_types")

21 CTD Thin

ctd_thin is an adaptively-thinned ctd_measurement: one cast direction per physical cast, canonical measurement types only, and a ~10 m depth grid with thermocline / halocline inflections preserved via Ramer–Douglas–Peucker line simplification. It is the headline CTD table; full ctd_measurement is retained as a supplemental output. See the Measurement Frequency section above for the size rationale.

21.1 Verify cast pairing

Code
# physical cast = (cruise_key, site_key, cast_base); direction comes from the
# cast_key suffix (trailing d/u), which is part of the deterministic
# ctd_cast_uuid key and fully consistent per cast. confirm casts are paired
# before thinning by direction.
ctd_pairing <- dbGetQuery(con, "
  WITH phys AS (
    SELECT DISTINCT
      cruise_key, site_key,
      regexp_replace(cast_key, '[duDU]$', '') AS cast_base,
      CASE WHEN RIGHT(cast_key, 1) IN ('d', 'D') THEN 'D'
           WHEN RIGHT(cast_key, 1) IN ('u', 'U') THEN 'U' END AS dir
    FROM ctd_cast)
  SELECT n_dirs, COUNT(*) AS n_physical_casts
  FROM (
    SELECT cruise_key, site_key, cast_base, COUNT(DISTINCT dir) AS n_dirs
    FROM phys GROUP BY 1, 2, 3)
  GROUP BY n_dirs ORDER BY n_dirs")

n_paired <- sum(ctd_pairing$n_physical_casts[ctd_pairing$n_dirs == 2])
n_single <- sum(ctd_pairing$n_physical_casts[ctd_pairing$n_dirs == 1])
cat(glue(
  "{format(n_paired, big.mark = ',')} physical casts have both directions, ",
  "{format(n_single, big.mark = ',')} have one"), "\n")
9,118 physical casts have both directions, 16 have one 
Code
# sanity: most casts should be paired — a near-zero n_paired means the pairing
# key is wrong and the single-direction lever would silently keep everything
stopifnot(
  "ctd_thin: cast pairing key looks wrong (no two-direction casts)" =
    n_paired > n_single)

ctd_pairing |>
  dt(caption = "Cast directions per physical cast", fname = "ctd_thin_pairing")

21.2 Choose a single cast direction

Code
# canonical measurement types for ctd_thin (one per property; see is_canonical
# in metadata/measurement_type.csv)
canon_types <- d_meas_type |>
  filter(str_detect(`_source_datasets`, "calcofi_ctd-cast"), is_canonical) |>
  pull(measurement_type)
canon_in <- paste0("'", canon_types, "'", collapse = ", ")
cat(glue("{length(canon_types)} canonical measurement types: ",
         "{paste(canon_types, collapse = ', ')}"), "\n")
33 canonical measurement types: beam_attenuation, btl_ammonium, btl_chlorophyll_a, btl_depth, btl_nitrate, btl_nitrite, btl_phaeopigment, btl_phosphate, btl_silicate, btl_temperature, dynamic_height, fluorescence_v, isus_v, oxygen_btl_ml_l, oxygen_btl_umol_kg, oxygen_ml_l_1, oxygen_ml_l_2, oxygen_ml_l_ave_sta_corr, oxygen_umol_kg_1, oxygen_umol_kg_2, oxygen_umol_kg_ave_sta_corr, par, ph, pressure, salinity_1, salinity_2, salinity_ave_corr, salinity_btl, sigma_theta_1, spar, specific_volume_anomaly, temperature_ave, transmissometer 
Code
# choose one direction per physical cast: prefer downcast, fall back to upcast.
# _chosen_dir maps every chosen-direction ctd_cast_uuid to its physical cast
# (phys_cast_id) and direction.
dbExecute(con, "
  CREATE OR REPLACE TEMP TABLE _chosen_dir AS
  WITH resolved AS (
    SELECT DISTINCT
      ctd_cast_uuid, cruise_key, site_key, cast_key,
      regexp_replace(cast_key, '[duDU]$', '') AS cast_base,
      CASE WHEN RIGHT(cast_key, 1) IN ('d', 'D') THEN 'D'
           WHEN RIGHT(cast_key, 1) IN ('u', 'U') THEN 'U' END AS dir
    FROM ctd_cast),
  pick AS (
    SELECT cruise_key, site_key, cast_base, cast_key, dir
    FROM (SELECT DISTINCT cruise_key, site_key, cast_base, cast_key, dir
          FROM resolved)
    QUALIFY ROW_NUMBER() OVER (
      PARTITION BY cruise_key, site_key, cast_base
      ORDER BY CASE dir WHEN 'D' THEN 1 WHEN 'U' THEN 2 ELSE 3 END,
               cast_key) = 1)
  SELECT
    r.ctd_cast_uuid,
    r.cruise_key,
    r.cruise_key || '|' || r.site_key || '|' || r.cast_base AS phys_cast_id,
    p.dir AS cast_dir
  FROM resolved r
  JOIN pick p USING (cruise_key, site_key, cast_base, cast_key)")
[1] 3542927
Code
n_chosen <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM _chosen_dir")$n
cat(glue("_chosen_dir: {format(n_chosen, big.mark = ',')} cast records ",
         "(single direction per physical cast)"), "\n")
_chosen_dir: 3,542,927 cast records (single direction per physical cast) 

21.3 10 m depth grid

Code
# depth backbone: every distinct depth a canonical measurement is recorded at,
# per chosen-direction physical cast
dbExecute(con, glue("
  CREATE OR REPLACE TEMP TABLE _profile_depths AS
  SELECT DISTINCT cd.phys_cast_id, m.depth_m
  FROM ctd_measurement m
  JOIN _chosen_dir cd ON m.ctd_cast_uuid = cd.ctd_cast_uuid
  WHERE m.measurement_type IN ({canon_in})"))
[1] 3921212
Code
# 10 m grid: the sample nearest each 10 m node per profile, plus the shallowest
# and deepest sample (RDP needs fixed profile endpoints)
dbExecute(con, "
  CREATE OR REPLACE TEMP TABLE _grid_depths AS
  WITH ranked AS (
    SELECT phys_cast_id, depth_m,
      ROW_NUMBER() OVER (
        PARTITION BY phys_cast_id, ROUND(depth_m / 10.0) * 10.0
        ORDER BY ABS(depth_m - ROUND(depth_m / 10.0) * 10.0), depth_m) AS rn
    FROM _profile_depths),
  endpoints AS (
    SELECT phys_cast_id, MIN(depth_m) AS depth_m FROM _profile_depths GROUP BY 1
    UNION
    SELECT phys_cast_id, MAX(depth_m) AS depth_m FROM _profile_depths GROUP BY 1)
  SELECT phys_cast_id, depth_m FROM ranked WHERE rn = 1
  UNION
  SELECT phys_cast_id, depth_m FROM endpoints")
[1] 406718
Code
n_prof <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM _profile_depths")$n
n_grid <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM _grid_depths")$n
cat(glue("_grid_depths: {format(n_grid, big.mark = ',')} depths on the 10 m grid ",
         "(from {format(n_prof, big.mark = ',')} profile depths)"), "\n")
_grid_depths: 406,718 depths on the 10 m grid (from 3,921,212 profile depths) 

21.4 Inflection points (Ramer–Douglas–Peucker)

Code
# Ramer-Douglas-Peucker line simplification: keep depths where a profile deviates
# from the local straight line by more than `eps` (captures thermocline /
# halocline inflections). pure DP — the ~10 m backbone is provided separately by
# _grid_depths, so RDP only adds genuine inflections on top of it.
rdp_keep <- function(x, y, eps) {
  n    <- length(x)
  keep <- rep(FALSE, n)
  if (n == 0L) return(keep)
  keep[c(1L, n)] <- TRUE
  if (n <= 2L) return(keep)
  stack <- list(c(1L, n))
  while (length(stack) > 0L) {
    seg   <- stack[[length(stack)]]
    stack[[length(stack)]] <- NULL
    i <- seg[1L]; j <- seg[2L]
    if (j - i < 2L) next
    idx <- (i + 1L):(j - 1L)
    dx  <- x[j] - x[i]
    dy  <- y[j] - y[i]
    den <- sqrt(dx * dx + dy * dy)
    d <- if (den == 0) {
      abs(y[idx] - y[i])
    } else {
      abs(dy * x[idx] - dx * y[idx] + x[j] * y[i] - y[j] * x[i]) / den
    }
    if (max(d) > eps) {
      k <- idx[which.max(d)]
      keep[k] <- TRUE
      stack[[length(stack) + 1L]] <- c(i, k)
      stack[[length(stack) + 1L]] <- c(k, j)
    }
  }
  keep
}

# per-variable tolerance in measurement units — the primary tuning knob, set so
# the median retained-sample gap lands near the 10 m grid spacing (a looser eps
# keeps fewer inflections; revisit with the spot-check in the verification section)
rdp_eps <- c(temperature_ave = 0.2, salinity_ave_corr = 0.04)

# run RDP per cruise to keep memory flat: pull the two key variables for
# chosen-direction casts, simplify each profile, collect retained depths
cruise_keys <- dbGetQuery(con, "SELECT DISTINCT cruise_key FROM _chosen_dir")$cruise_key

rdp_retained <- purrr::map(cruise_keys, function(ck) {
  d_prof <- dbGetQuery(con, glue("
    SELECT cd.phys_cast_id, m.measurement_type, m.depth_m,
           AVG(m.measurement_value) AS measurement_value
    FROM ctd_measurement m
    JOIN _chosen_dir cd
      ON m.ctd_cast_uuid = cd.ctd_cast_uuid AND cd.cruise_key = '{ck}'
    WHERE m.cruise_key = '{ck}'
      AND m.measurement_type IN ('temperature_ave', 'salinity_ave_corr')
      AND m.measurement_value IS NOT NULL
    GROUP BY cd.phys_cast_id, m.measurement_type, m.depth_m"))
  if (nrow(d_prof) == 0) return(NULL)
  d_prof |>
    arrange(phys_cast_id, measurement_type, depth_m) |>
    group_by(phys_cast_id, measurement_type) |>
    filter(rdp_keep(depth_m, measurement_value,
                    eps = rdp_eps[[measurement_type[1]]])) |>
    ungroup() |>
    distinct(phys_cast_id, depth_m)
}) |>
  purrr::list_rbind() |>
  distinct(phys_cast_id, depth_m)

dbWriteTable(con, "_rdp_retained", rdp_retained,
             temporary = TRUE, overwrite = TRUE)
cat(glue("_rdp_retained: {format(nrow(rdp_retained), big.mark = ',')} ",
         "(profile, depth) pairs flagged as inflections"), "\n")
_rdp_retained: 192,895 (profile, depth) pairs flagged as inflections 

21.5 Assemble ctd_thin

Code
# Union three retention reasons, in precedence order: 10 m grid, RDP inflections,
# then bottle-trip depths.
#
# The third is why this section changed. Thinning is designed for DENSE SENSOR
# SCANS: the 10 m backbone and the RDP inflections are both derived from
# temperature/salinity profiles, so a depth is kept because the *sensor* profile
# bends there. Bottle values are not a profile — they are a handful of discrete,
# lab-analysed samples per cast at depths chosen by the watch, and nothing about
# the sensor profile knows where they are. Selecting depths by sensor geometry and
# then subsetting bottle values to those depths silently discards most of them.
#
# Measured against release v2026.07.17: of 133,206 distinct (cast, depth) bottle
# ammonium measurements, only 35,549 (26.7%) sit at a depth this logic retained.
# Roughly half the loss is the cast-direction choice (by design); the rest was
# depth thinning throwing away real lab samples. So flagging btl_ammonium as
# canonical is necessary but NOT sufficient — without this clause it would have
# landed about a quarter of the ammonium record in `obs` and looked like success.
#
# A bottle depth costs nothing to keep (a few rows per cast) and cannot be
# reconstructed by interpolation, so retain every one carrying a canonical value.
canon_btl <- d_meas_ctd |>
  filter(is_canonical, str_starts(measurement_type, "btl_") |
           measurement_type %in% c("salinity_btl", "oxygen_btl_ml_l",
                                   "oxygen_btl_umol_kg")) |>
  pull(measurement_type)
say(glue("bottle-grain canonical types retained whole: ",
         "{if (length(canon_btl)) paste(canon_btl, collapse = ', ') else '(none)'}"))
bottle-grain canonical types retained whole: btl_ammonium, btl_chlorophyll_a, btl_depth, btl_nitrate, btl_nitrite, btl_phaeopigment, btl_phosphate, btl_silicate, btl_temperature, oxygen_btl_ml_l, oxygen_btl_umol_kg, salinity_btl
Code
btl_clause <- if (length(canon_btl)) glue("
  UNION
  SELECT cd.phys_cast_id, m.depth_m, 'bottle' AS retained_reason
  FROM ctd_measurement m
  JOIN _chosen_dir cd ON m.ctd_cast_uuid = cd.ctd_cast_uuid
  WHERE m.measurement_type IN ({paste(sprintf(\"'%s'\", canon_btl), collapse = ', ')})
    AND NOT EXISTS (
      SELECT 1 FROM _grid_depths g
      WHERE g.phys_cast_id = cd.phys_cast_id AND g.depth_m = m.depth_m)
    AND NOT EXISTS (
      SELECT 1 FROM _rdp_retained r
      WHERE r.phys_cast_id = cd.phys_cast_id AND r.depth_m = m.depth_m)") else ""

dbExecute(con, glue("
  CREATE OR REPLACE TEMP TABLE _retained_depths AS
  SELECT phys_cast_id, depth_m, 'grid' AS retained_reason FROM _grid_depths
  UNION
  SELECT r.phys_cast_id, r.depth_m, 'inflection' AS retained_reason
  FROM _rdp_retained r
  WHERE NOT EXISTS (
    SELECT 1 FROM _grid_depths g
    WHERE g.phys_cast_id = r.phys_cast_id AND g.depth_m = r.depth_m)
  {btl_clause}"))
[1] 645227
Code
# ctd_thin: ctd_measurement rows that are a canonical type, on the chosen
# direction, at a retained depth — a pure row subset (values never interpolated).
# the QUALIFY de-dups defensively on (ctd_cast_uuid, depth_m, measurement_type):
# the dedup_ctd_raw chunk above already makes ctd_measurement unique, so this is
# a no-op safeguard that keeps ctd_thin's PK guaranteed-unique on its own.
dbExecute(con, glue("
  CREATE OR REPLACE TABLE ctd_thin AS
  WITH candidates AS (
    SELECT
      m.ctd_cast_uuid,
      m.depth_m,
      m.measurement_type,
      m.measurement_value,
      m.measurement_qual,
      cd.cast_dir,
      rd.retained_reason,
      m.cruise_key
    FROM ctd_measurement m
    JOIN _chosen_dir cd      ON m.ctd_cast_uuid = cd.ctd_cast_uuid
    JOIN _retained_depths rd ON rd.phys_cast_id = cd.phys_cast_id
                            AND rd.depth_m      = m.depth_m
    WHERE m.measurement_type IN ({canon_in}))
  SELECT *
  FROM candidates
  QUALIFY ROW_NUMBER() OVER (
    PARTITION BY ctd_cast_uuid, depth_m, measurement_type
    ORDER BY measurement_qual NULLS LAST, measurement_value) = 1
  ORDER BY cruise_key, ctd_cast_uuid, measurement_type, depth_m"))
[1] 12657129
Code
n_thin <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_thin")$n
n_meas <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_measurement")$n
cat(glue(
  "ctd_thin: {format(n_thin, big.mark = ',')} rows ",
  "({round(100 * n_thin / n_meas, 1)}% of ctd_measurement's ",
  "{format(n_meas, big.mark = ',')})"), "\n")
ctd_thin: 12,657,129 rows (4.9% of ctd_measurement's 259,309,891) 
Code
# deterministic PK from the natural key, mirroring ctd_measurement
assign_deterministic_uuids_md5(
  con        = con,
  table_name = "ctd_thin",
  id_col     = "ctd_thin_uuid",
  key_cols   = c("ctd_cast_uuid", "depth_m", "measurement_type"))

# drop scratch tables; _chosen_dir is kept for the verification chunk that
# follows and dropped there (downstream metadata helpers enumerate all DB tables)
dbExecute(con, "DROP TABLE IF EXISTS _profile_depths")
[1] 0
Code
dbExecute(con, "DROP TABLE IF EXISTS _grid_depths")
[1] 0
Code
dbExecute(con, "DROP TABLE IF EXISTS _rdp_retained")
[1] 0
Code
dbExecute(con, "DROP TABLE IF EXISTS _retained_depths")
[1] 0

21.6 Verify ctd_thin

Code
# 1. row count + thinning ratio
n_thin <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_thin")$n
n_meas <- dbGetQuery(con, "SELECT COUNT(*) AS n FROM ctd_measurement")$n
cat(glue("ctd_thin: {format(n_thin, big.mark = ',')} rows = ",
         "{round(100 * n_thin / n_meas, 1)}% of ctd_measurement"), "\n")
ctd_thin: 12,657,129 rows = 4.9% of ctd_measurement 
Code
stopifnot(
  "ctd_thin is empty"                          = n_thin > 0,
  "ctd_thin not smaller than ctd_measurement"  = n_thin < n_meas)

# 2. measurement_type is a subset of the canonical list
thin_types <- dbGetQuery(con,
  "SELECT DISTINCT measurement_type FROM ctd_thin")$measurement_type
stopifnot("ctd_thin has non-canonical measurement_type" =
  length(setdiff(thin_types, canon_types)) == 0)

# 3. ctd_thin_uuid is unique
stopifnot("ctd_thin_uuid not unique" = dbGetQuery(con,
  "SELECT COUNT(*) - COUNT(DISTINCT ctd_thin_uuid) AS n FROM ctd_thin")$n == 0)

# 4. faithful subset of ctd_measurement — every ctd_thin row corresponds to a
#    real ctd_measurement row with the same value (thinning selects rows, never
#    interpolates). a NOT EXISTS check, since ctd_measurement has duplicate keys.
stopifnot("ctd_thin rows not a faithful subset of ctd_measurement" =
  dbGetQuery(con, "
    SELECT COUNT(*) AS n
    FROM ctd_thin t
    WHERE NOT EXISTS (
      SELECT 1 FROM ctd_measurement m
      WHERE m.ctd_cast_uuid     = t.ctd_cast_uuid
        AND m.depth_m           = t.depth_m
        AND m.measurement_type  = t.measurement_type
        AND m.measurement_value IS NOT DISTINCT FROM t.measurement_value)")$n == 0)

# 5. retained_reason populated with the expected categories. 'bottle' joins
#    'grid'/'inflection' only when a canonical bottle-grain type exists (see the
#    assemble chunk), so it is required conditionally rather than always.
reason_breakdown <- dbGetQuery(con,
  "SELECT retained_reason, COUNT(*) AS n, COUNT(DISTINCT depth_m) AS n_depths
   FROM ctd_thin GROUP BY 1 ORDER BY n DESC")
expected_reasons <- c("grid", "inflection", if (length(canon_btl)) "bottle")
stopifnot(
  "ctd_thin retained_reason has unexpected values" =
    all(reason_breakdown$retained_reason %in% expected_reasons),
  "ctd_thin retained_reason missing a category" =
    all(expected_reasons %in% reason_breakdown$retained_reason))
# previously computed only for the assertion and never shown
say("retained_reason breakdown:")
retained_reason breakdown:
Code
print(reason_breakdown)
  retained_reason       n n_depths
1            grid 7668264     2046
2      inflection 2795130     1511
3          bottle 2193735      613
Code
# 5b. the point of the 'bottle' reason: every canonical bottle-grain measurement on
#     a chosen-direction cast must survive thinning, not just those that happened to
#     land on a sensor-derived depth. Assert 100% retention rather than trusting it.
if (length(canon_btl)) {
  btl_in <- paste(sprintf("'%s'", canon_btl), collapse = ", ")
  btl_cov <- dbGetQuery(con, glue("
    WITH avail AS (
      SELECT m.ctd_cast_uuid, m.depth_m, m.measurement_type
      FROM ctd_measurement m JOIN _chosen_dir cd USING (ctd_cast_uuid)
      WHERE m.measurement_type IN ({btl_in}))
    SELECT (SELECT COUNT(*) FROM avail) AS available,
           (SELECT COUNT(*) FROM ctd_thin WHERE measurement_type IN ({btl_in})) AS retained"))
  say(glue("bottle-grain canonical: {format(btl_cov$retained, big.mark = ',')} of ",
           "{format(btl_cov$available, big.mark = ',')} retained ",
           "({round(100 * btl_cov$retained / btl_cov$available, 1)}%)"))
  stopifnot("every canonical bottle measurement on the chosen direction must be retained" =
              btl_cov$retained == btl_cov$available)
}
bottle-grain canonical: 1,489,181 of 1,489,181 retained (100%)
Code
# 6. one cast direction per physical cast; no physical cast dropped entirely
coverage <- dbGetQuery(con, glue("
  WITH meas_casts AS (
    SELECT DISTINCT cd.phys_cast_id
    FROM ctd_measurement m
    JOIN _chosen_dir cd ON m.ctd_cast_uuid = cd.ctd_cast_uuid
    WHERE m.measurement_type IN ({canon_in})),
  thin AS (
    SELECT cd.phys_cast_id, COUNT(DISTINCT t.cast_dir) AS n_dir
    FROM ctd_thin t
    JOIN _chosen_dir cd ON t.ctd_cast_uuid = cd.ctd_cast_uuid
    GROUP BY cd.phys_cast_id)
  SELECT
    (SELECT COUNT(*) FROM thin)                  AS n_thin_casts,
    (SELECT COUNT(*) FROM thin WHERE n_dir > 1)  AS n_multi_dir,
    (SELECT COUNT(*) FROM (
       SELECT phys_cast_id FROM meas_casts
       EXCEPT
       SELECT phys_cast_id FROM thin))           AS n_dropped"))
stopifnot(
  "ctd_thin: physical cast with >1 direction" = coverage$n_multi_dir == 0,
  "ctd_thin: physical cast dropped entirely"  = coverage$n_dropped   == 0)
cat(glue("{format(coverage$n_thin_casts, big.mark = ',')} physical casts ",
         "retained (single direction each)"), "\n")
9,134 physical casts retained (single direction each) 
Code
# 7. depth-gap distribution between retained samples
gap_stats <- dbGetQuery(con, "
  WITH g AS (
    SELECT t.depth_m - LAG(t.depth_m) OVER (
             PARTITION BY cd.phys_cast_id, t.measurement_type
             ORDER BY t.depth_m) AS gap_m
    FROM ctd_thin t
    JOIN _chosen_dir cd ON t.ctd_cast_uuid = cd.ctd_cast_uuid)
  SELECT
    ROUND(MEDIAN(gap_m), 2)              AS median_gap_m,
    ROUND(QUANTILE_CONT(gap_m, 0.95), 2) AS p95_gap_m,
    ROUND(MAX(gap_m), 2)                 AS max_gap_m
  FROM g WHERE gap_m IS NOT NULL")
cat(glue("depth gaps between retained samples: median {gap_stats$median_gap_m} m, ",
         "p95 {gap_stats$p95_gap_m} m, max {gap_stats$max_gap_m} m"), "\n")
depth gaps between retained samples: median 8 m, p95 16 m, max 14600 m 
Code
# 8. every ctd_thin type is registered as canonical in measurement_type
stopifnot("ctd_thin types not registered as canonical in measurement_type" =
  length(dbGetQuery(con, "
    SELECT DISTINCT t.measurement_type
    FROM ctd_thin t
    LEFT JOIN measurement_type mt ON t.measurement_type = mt.measurement_type
    WHERE mt.measurement_type IS NULL OR NOT mt.is_canonical")$measurement_type) == 0)

# done with _chosen_dir — drop before downstream metadata helpers enumerate tables
dbExecute(con, "DROP TABLE IF EXISTS _chosen_dir")
[1] 0
Code
reason_breakdown |>
  dt(caption = "ctd_thin rows by retained_reason", fname = "ctd_thin_reason")

21.7 Spot-check thinned profiles

Overlay the full ctd_measurement profile (line) against the ctd_thin samples (points) for the six temperature profiles with the most retained inflection points — confirming thermocline curvature is tracked while flat segments stay sparse.

Code
# a ctd_cast_uuid is a single depth sample, not a profile — group by the
# physical cast (cruise_key + cast_key). pick six temperature profiles with the
# most retained inflection points (the hardest cases to thin faithfully).
cast_map <- "(SELECT DISTINCT ctd_cast_uuid, cast_key FROM ctd_cast)"

spot <- dbGetQuery(con, glue("
  WITH thin AS (
    SELECT t.cruise_key, cm.cast_key, t.retained_reason
    FROM ctd_thin t
    JOIN {cast_map} cm ON t.ctd_cast_uuid = cm.ctd_cast_uuid
    WHERE t.measurement_type = 'temperature_ave')
  SELECT cruise_key, cast_key
  FROM thin
  GROUP BY cruise_key, cast_key
  HAVING COUNT(*) >= 20
  ORDER BY COUNT(*) FILTER (WHERE retained_reason = 'inflection') DESC,
           cruise_key, cast_key
  LIMIT 6"))
spot$cast <- paste("cast", seq_len(nrow(spot)))
spot_in <- if (nrow(spot) > 0) {
  paste(sprintf("('%s', '%s')", spot$cruise_key, spot$cast_key), collapse = ", ")
} else "('', '')"

# full ctd_measurement profile (de-duplicated) and the thinned samples
d_full <- dbGetQuery(con, glue("
  SELECT t.cruise_key, cm.cast_key, t.depth_m,
         AVG(t.measurement_value) AS measurement_value
  FROM ctd_measurement t
  JOIN {cast_map} cm ON t.ctd_cast_uuid = cm.ctd_cast_uuid
  WHERE t.measurement_type = 'temperature_ave'
    AND (t.cruise_key, cm.cast_key) IN ({spot_in})
  GROUP BY t.cruise_key, cm.cast_key, t.depth_m
  ORDER BY t.cruise_key, cm.cast_key, t.depth_m"))
d_thin <- dbGetQuery(con, glue("
  SELECT t.cruise_key, cm.cast_key, t.depth_m, t.measurement_value,
         t.retained_reason
  FROM ctd_thin t
  JOIN {cast_map} cm ON t.ctd_cast_uuid = cm.ctd_cast_uuid
  WHERE t.measurement_type = 'temperature_ave'
    AND (t.cruise_key, cm.cast_key) IN ({spot_in})
  ORDER BY t.cruise_key, cm.cast_key, t.depth_m"))

# short facet labels keyed on cruise_key + cast_key
prof_key  <- function(d) paste(d$cruise_key, d$cast_key)
cast_lab  <- setNames(spot$cast, prof_key(spot))
d_full$cast <- cast_lab[prof_key(d_full)]
d_thin$cast <- cast_lab[prof_key(d_thin)]

ggplot2::ggplot() +
  ggplot2::geom_path(
    data = d_full, ggplot2::aes(measurement_value, depth_m),
    color = "grey70", linewidth = 0.3) +
  ggplot2::geom_point(
    data = d_thin,
    ggplot2::aes(measurement_value, depth_m, color = retained_reason),
    size = 1.1) +
  ggplot2::scale_y_reverse() +
  ggplot2::scale_color_manual(
    values = c(grid = "#0077cc", inflection = "#ff6600")) +
  ggplot2::facet_wrap(~ cast, scales = "free", nrow = 2) +
  ggplot2::labs(
    x = "temperature_ave (degC)", y = "depth (m)", color = "retained",
    title = "Full ctd_measurement profile (line) vs ctd_thin samples (points)") +
  ggplot2::theme_minimal(base_size = 9) +
  ggplot2::theme(legend.position = "bottom")

22 Schema Diagram

Code
# define PK/FK relationships for visualization and relationships.json
ctd_rels <- list(
  primary_keys = list(
    ctd_cast        = "ctd_cast_uuid",
    ctd_measurement = "ctd_measurement_uuid",
    ctd_thin        = "ctd_thin_uuid",
    ctd_summary     = "ctd_summary_uuid",
    measurement_type = "measurement_type"),
  foreign_keys = list(
    list(table = "ctd_measurement", column = "ctd_cast_uuid", ref_table = "ctd_cast", ref_column = "ctd_cast_uuid"),
    list(table = "ctd_thin", column = "ctd_cast_uuid", ref_table = "ctd_cast", ref_column = "ctd_cast_uuid"),
    list(table = "ctd_measurement", column = "measurement_type", ref_table = "measurement_type", ref_column = "measurement_type"),
    list(table = "ctd_thin", column = "measurement_type", ref_table = "measurement_type", ref_column = "measurement_type"),
    list(table = "ctd_summary", column = "measurement_type", ref_table = "measurement_type", ref_column = "measurement_type")))

cc_erd(con, rels = ctd_rels)

23 Add Spatial

Code
add_point_geom(con, "ctd_cast", lon_col = "longitude", lat_col = "latitude")

# grid already loaded in Cross-Dataset Bridge section
assign_grid_key(con, "ctd_cast")
   status       n
1 in_grid 7060753

24 Derived View

Code
dbExecute(
  con,
  "
  CREATE OR REPLACE VIEW ctd_cast_derived AS
  SELECT *,
    datetime_start_utc AT TIME ZONE 'UTC' AT TIME ZONE 'US/Pacific' AS datetime_pst,
    EXTRACT(YEAR FROM datetime_start_utc)  AS year,
    EXTRACT(MONTH FROM datetime_start_utc) AS month,
    EXTRACT(DOY FROM datetime_start_utc)   AS julian_day
  FROM ctd_cast"
)
[1] 0

25 Load Dataset Metadata

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

26 Data Quality Diagnostics

Range and sentinel audit over ctd_measurement. This exists because the v2026.07.17 release shipped values that cannot be real — salinity_ave_corr spanning −45 to 1016 PSU, oxygen maxima around 1e9–1e10 — alongside -99 sentinels that were ingested as genuine measurements. Nothing in the pipeline flagged either, so this section makes both visible at ingest time.

NoteEmpty on a fast re-render — see Findings instead

This section audits the intermediate ctd_measurement table, which only exists when the heavy path ran. On a re-render with unchanged inputs its chunks are shown but not evaluated, so they have no output (see Check for Resumable State).

That is not a gap in coverage: the equivalent checks over the published obs and obs_ctd_full are in Findings, which reads the parquet and therefore always runs. This section is the pre-write audit; that one is the as-shipped report.

Important-99 is fixed at the source; the extremes are not

-99 is handled. The pivot above now deletes it, because the notebook already treated -99 as this source’s missing marker for longitude/latitude — the rule had simply never been extended to the measurement columns. The audit below should therefore report zero -99 rows; if it does not, that fix has regressed.

The extremes are still only reported. They do not stopifnot(): until the providers confirm whether values like 2.1e9 mL/L oxygen are bad scans or a units error (question 02), dropping them would be guessing. The plausible ranges below are a first pass and belong in metadata/measurement_type.csv as valid_min/valid_max once agreed — which would also let the CF netCDF writer emit them as real valid_min/valid_max variable attributes.

Code
# audit trail for the -99 deletion in the pivot above
if (nrow(sentinel_tally)) {
  sentinel_tally |>
    mutate(pct_of_removed = round(100 * n / sum(n), 1)) |>
    datatable(
      caption  = glue("-99 sentinel rows removed from ctd_measurement ",
                      "({format(n_sentinel, big.mark = ',')} total)"),
      rownames = FALSE,
      options  = list(pageLength = 10, scrollX = TRUE, dom = "tip"))
} else {
  say("no -99 sentinel values found in ctd_measurement")
}
Code
# Plausible physical ranges now come from the REGISTRY, not a tribble local to this
# chunk — `metadata/measurement_type.csv` columns valid_min / valid_max, populated by
# libs/build_ctd_measurement_registry.R. Same values as the inline table they replace;
# declaring them once means they are reviewable in a diff, emittable by the CF netCDF
# writer as real valid_min/valid_max variable attributes, and available to any QC rule
# rather than only to this audit.
#
# Still deliberately generous, and still report-only: they catch impossible values,
# they are not the agreed oceanographic ranges, and nothing below drops a row.
plaus <- d_meas_type |>
  filter(!is.na(valid_min) | !is.na(valid_max)) |>
  select(measurement_type, lo = valid_min, hi = valid_max)

diag_sql <- glue("
  SELECT measurement_type,
         COUNT(*)                                           AS n,
         COUNT(*) FILTER (WHERE measurement_value = -99)     AS n_neg99,
         COUNT(*) FILTER (WHERE isnan(measurement_value)
                             OR NOT isfinite(measurement_value)) AS n_nonfinite,
         MIN(measurement_value)                             AS v_min,
         MAX(measurement_value)                             AS v_max
  FROM ctd_measurement GROUP BY 1")
d_diag <- dbGetQuery(con, diag_sql) |>
  left_join(plaus, by = "measurement_type") |>
  mutate(
    pct_neg99   = round(100 * n_neg99 / n, 2),
    # count out-of-range only where a plausible range is declared, and exclude the
    # -99 sentinel so it is reported once (as a sentinel) rather than twice
    has_range   = !is.na(lo),
    out_of_range = NA_integer_)

for (i in which(d_diag$has_range)) {
  r <- d_diag[i, ]
  d_diag$out_of_range[i] <- dbGetQuery(con, glue("
    SELECT COUNT(*) AS n FROM ctd_measurement
    WHERE measurement_type = '{r$measurement_type}'
      AND measurement_value <> -99
      AND (measurement_value < {r$lo} OR measurement_value > {r$hi})"))$n
}

say(glue("types audited                     : {nrow(d_diag)}"))
types audited                     : 54
Code
say(glue("  with a declared plausible range : {sum(d_diag$has_range)}"))
  with a declared plausible range : 39
Code
say(glue("  WITHOUT a range (unchecked)     : {sum(!d_diag$has_range)}"))
  WITHOUT a range (unchecked)     : 15
Code
say(glue("-99 sentinel values (all types)   : ",
         "{format(sum(d_diag$n_neg99), big.mark = ',')}"))
-99 sentinel values (all types)   : 0
Code
say(glue("out-of-range values (ranged types): ",
         "{format(sum(d_diag$out_of_range, na.rm = TRUE), big.mark = ',')}"))
out-of-range values (ranged types): 0
Code
d_diag |>
  transmute(
    measurement_type, n,
    `-99` = n_neg99, `%-99` = pct_neg99, `non-finite` = n_nonfinite,
    min = signif(v_min, 4), max = signif(v_max, 4),
    range = if_else(has_range, glue("{lo}..{hi}"), glue("—")),
    `out of range` = out_of_range) |>
  arrange(desc(`-99` + coalesce(`out of range`, 0L)), measurement_type) |>
  datatable(
    caption  = paste("ctd_measurement range + sentinel audit —",
                     "rows with -99 or out-of-range values sort first"),
    rownames = FALSE,
    options  = list(pageLength = 12, scrollX = TRUE, dom = "ftip")) |>
  formatStyle("-99",          color = styleInterval(0, c("inherit", "#d03b3b"))) |>
  formatStyle("out of range", color = styleInterval(0, c("inherit", "#d03b3b")))

26.1 Quality flags — decoded against the controlled vocabulary

measurement_qual was a verbatim pass-through for years: carried through every table, never interpreted, with no vocabulary anywhere in the repo. There is one now — metadata/measurement_qual.csv, recovered from the CalCOFI hydrographic master’s own field documentation (6 = data OK but taken from CTD, 8 = value is suspect, 9 = missing data) and confirmed to apply to CTD, whose flags use the same codes.

WarningDecoded and reported — deliberately not acted on

Nothing here drops or rewrites a flagged value. Three questions are open with the data providers (metadata/calcofi/hydro-master/questions.csv, two of them blockers): what the undocumented single-digit codes mean, whether a NULL flag means “good” or “never assessed”, and whether S_qual’s 253 distinct values in the bottle master indicate a bitmask or a corrupted column. Acting on a flag whose semantics are unconfirmed would be guessing with released data.

The headline variables carry no flags at all, and that is the more important gap: source flags attach to the component sensors (Temp1Q, Salt1Q, Ox1Q, Ox2Q), while the canonical types are the averages — so quality information is lost in the mean. Deciding that propagation rule (worst-of? both-must-pass?) is ctd-qaqc work, not something to invent here.

Code
# qual_code is forced to character: every value in the vocabulary is numeric, so a
# type-guessing read makes it a double, and joining that to the VARCHAR
# measurement_qual is a hard error rather than a silent miss.
d_qual_vocab <- read_csv(
  here("metadata/measurement_qual.csv"),
  col_types = cols(qual_code = col_character(), .default = col_guess())) |>
  select(measurement_qual = qual_code, label, description, is_documented)

d_qual_obs <- dbGetQuery(con, "
  SELECT measurement_qual, COUNT(*) AS n,
         COUNT(DISTINCT measurement_type) AS n_types
  FROM ctd_measurement
  WHERE measurement_qual IS NOT NULL
  GROUP BY 1 ORDER BY n DESC") |>
  left_join(d_qual_vocab, by = "measurement_qual") |>
  mutate(
    label         = coalesce(label, "UNRECOGNIZED"),
    is_documented = coalesce(is_documented, FALSE))

n_flagged   <- sum(d_qual_obs$n)
n_unrecog   <- sum(d_qual_obs$n[d_qual_obs$label == "UNRECOGNIZED"])
n_canon_flg <- dbGetQuery(con, glue("
  SELECT COUNT(DISTINCT measurement_type) AS n FROM ctd_measurement
  WHERE measurement_qual IS NOT NULL
    AND measurement_type IN ({canon_in})"))$n

say(glue("flagged measurements        : {format(n_flagged, big.mark = ',')}"))
flagged measurements        : 169,823
Code
say(glue("  codes seen                : {nrow(d_qual_obs)}"))
  codes seen                : 5
Code
say(glue("  NOT in the vocabulary     : {format(n_unrecog, big.mark = ',')}"))
  NOT in the vocabulary     : 53
Code
say(glue("canonical types carrying any flag: {n_canon_flg} of {length(canon_types)}"))
canonical types carrying any flag: 12 of 33
Code
# a code outside the vocabulary is a finding, not noise — surface it loudly rather
# than letting a left_join quietly render it as NA in the table below
if (n_unrecog > 0) {
  say(glue("  unrecognized code(s): ",
           "{paste(d_qual_obs$measurement_qual[d_qual_obs$label == 'UNRECOGNIZED'], collapse = ', ')}"))
}
  unrecognized code(s): nan
Code
d_qual_obs |>
  transmute(code = measurement_qual, label, description,
            documented = is_documented, n, `types` = n_types) |>
  datatable(
    caption  = paste("ctd_measurement quality flags decoded against",
                     "metadata/measurement_qual.csv"),
    rownames = FALSE,
    options  = list(pageLength = 10, scrollX = TRUE, dom = "tip")) |>
  formatStyle("documented",
              color = styleEqual(c(TRUE, FALSE), c("inherit", "#d03b3b")))

27 Validate and Enforce Types

Code
validate_for_release(con)
$passed
[1] TRUE

$checks
# A tibble: 11 × 4
   check        table            status  message                                
   <chr>        <chr>            <chr>   <glue>                                 
 1 row_counts   cruise           pass    691 rows                               
 2 row_counts   ctd_cast         pass    7060753 rows                           
 3 row_counts   ctd_cast_derived pass    7060753 rows                           
 4 row_counts   ctd_measurement  pass    259309891 rows                         
 5 row_counts   ctd_summary      pass    128405409 rows                         
 6 row_counts   ctd_thin         pass    12657129 rows                          
 7 row_counts   dataset          pass    16 rows                                
 8 row_counts   grid             pass    218 rows                               
 9 row_counts   measurement_type pass    200 rows                               
10 row_counts   ship             pass    48 rows                                
11 completeness <NA>             warning Missing expected tables: site, tow, ne…

$errors
character(0)

$warnings
[1] "Missing expected tables: site, tow, net, larva, species"
Code
enforce_column_types(con, d_flds_rd = d_flds_rd)
# A tibble: 0 × 5
# ℹ 5 variables: table <chr>, column <chr>, from_type <chr>, to_type <chr>,
#   success <lgl>

28 Preview Tables

One datatable() per table, each in its own chunk — preview_tables() in a loop renders only the first DT widget (see .claude/skills/ingest-new/SKILL.md), which is why several of these previews were previously invisible. Every preview is horizontally scrollable (scrollX) and column-capped: ctd_cast is ~40 columns wide and overflowed the page as a static table.

Code
# shared preview: LIMIT in SQL (never collect a 216M-row table), scrollX so wide
# tables scroll inside their own box instead of overflowing the article, and
# geometry dropped (the R DuckDB driver cannot marshal GEOMETRY).
preview_tbl <- function(tbl, n = 100, cols = NULL) {
  all_cols <- dbGetQuery(con, glue(
    "SELECT column_name, data_type FROM information_schema.columns
      WHERE table_name = '{tbl}'"))
  keep <- all_cols$column_name[all_cols$data_type != "GEOMETRY"]
  if (!is.null(cols)) keep <- intersect(keep, cols)
  n_row <- dbGetQuery(con, glue("SELECT COUNT(*) AS n FROM {tbl}"))$n
  d <- dbGetQuery(con, glue(
    "SELECT {paste(keep, collapse = ', ')} FROM {tbl} LIMIT {n}"))
  datatable(
    d, rownames = FALSE,
    caption = glue("{tbl} — {format(n_row, big.mark = ',')} rows x ",
                   "{nrow(all_cols)} cols (showing first {nrow(d)} rows",
                   if (!is.null(cols)) ", selected cols" else "", ")"),
    options = list(pageLength = 5, scrollX = TRUE, dom = "tip"))
}
Code
# ~40 columns; show the identity + position core and let scrollX handle the rest
preview_tbl("ctd_cast", cols = c(
  "ctd_cast_uuid", "cruise_key", "site_key", "cast_key", "cast_dir",
  "datetime_start_utc", "latitude", "longitude", "ship_key", "data_stage",
  "ord_occ"))
Code
preview_tbl("ctd_measurement")
Code
preview_tbl("ctd_thin")
Code
preview_tbl("ctd_summary")
Code
preview_tbl("measurement_type")

29 Emit Core Tables

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

Two grains ship from here. The default obs carries the thinned ctd_thin series (~5.5M rows) at realm = 'env', which is what every consumer reads. The full-resolution scan set becomes obs_ctd_full (~216M rows), a supplemental output that is hosted and catalogued but excluded from the default table list — cc_get_db(supplemental = TRUE) opts into it. Building it was previously the release’s job, reaching back into this ingest’s ctd_measurement parquet; it belongs here, with the dataset that produces it.

sample is one row per physical cast (cast_key), not per scan: ctd_cast is per-scan, so the arm deduplicates to the cast before minting the event.

Code
ds_key <- "calcofi_ctd-cast"

# This projection lives here, in the notebook that owns the dataset, rather than in
# a switch(dataset_key, …) arm inside calcofi4db. No taxa: CTD is the env realm.
#
# 1. sample — one row per PHYSICAL cast. ctd_cast is per-scan, so the QUALIFY is
#    load-bearing: without it `sample` would get one row per scan (see the
#    scan-grain gotcha in CLAUDE.md).
#    16 columns, not 15: `data_stage` is append_sample()'s optional trailing
#    column, so the final/preliminary distinction the source insists on ("for
#    non-publication use") reaches consumers instead of dying here (question
#    calcofi_ctd-cast_14).
append_sample(con, glue("
  SELECT * FROM (
    SELECT {ns_key(ds_key, 'cast', 'cast_key')} AS sample_key, 'cast' AS sample_type,
           NULL::VARCHAR AS parent_sample_key,
           {ns_key(ds_key, 'cast', 'cast_key')} AS root_sample_key,
           '{ds_key}' AS dataset_key, grid_key, site_key, cruise_key,
           TRY_CAST(ord_occ AS INTEGER) AS order_occ, latitude, longitude,
           CAST(datetime_start_utc AS TIMESTAMP) AS datetime,
           NULL::DOUBLE AS depth_min_m, NULL::DOUBLE AS depth_max_m,
           NULL::VARCHAR AS tow_type, data_stage
    FROM ctd_cast
  ) q QUALIFY row_number() OVER (PARTITION BY sample_key ORDER BY datetime) = 1"))

# 2. obs — the THINNED scan set is the headline (ctd_thin, not ctd_measurement);
#    full resolution goes to the supplemental obs_ctd_full below.
append_obs(con, glue("
  SELECT 'env', '{ds_key}',
         {ns_key(ds_key, 'cast', 'cc.cast_key')},
         cc.grid_key, cc.cruise_key, cc.latitude, cc.longitude,
         CAST(cc.datetime_start_utc AS TIMESTAMP), t.depth_m, t.depth_m,
         NULL::VARCHAR, NULL::VARCHAR, t.measurement_type, t.measurement_value,
         t.measurement_qual, NULL::DOUBLE
  FROM ctd_thin t JOIN ctd_cast cc ON t.ctd_cast_uuid = cc.ctd_cast_uuid"))

core <- list(
  sample = dbGetQuery(con, "SELECT COUNT(*) FROM sample")[[1]],
  obs    = dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]])
cat(glue(
  "core projection — sample={core$sample %||% 0} obs={core$obs %||% 0}\n"))
core projection — sample=18250 obs=12657129
Code
n_obs <- dbGetQuery(con, "SELECT COUNT(*) FROM obs")[[1]]
n_exp <- dbGetQuery(con,
  "SELECT COUNT(*) FROM ctd_thin t JOIN ctd_cast cc ON t.ctd_cast_uuid = cc.ctd_cast_uuid")[[1]]
n_cast <- dbGetQuery(con, "SELECT COUNT(DISTINCT cast_key) FROM ctd_cast")[[1]]
stopifnot(
  "obs must be one row per thinned scan" = n_obs == n_exp,
  "sample must be one row per PHYSICAL cast, not per scan" =
    dbGetQuery(con, "SELECT COUNT(*) FROM sample")[[1]] == n_cast,
  "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,
  # ctd_cast is filtered to CTD_DATA_STAGES upstream, so a NULL here means the
  # 16th positional column silently fell off the arm rather than that a cast has
  # no stage
  "every sample row must carry a known data_stage" =
    dbGetQuery(con, glue(
      "SELECT COUNT(*) FROM sample
       WHERE data_stage NOT IN ({stages_sql}) OR data_stage IS NULL",
      stages_sql = paste0("'", CTD_DATA_STAGES, "'", collapse = ", ")))[[1]] == 0,
  # the whole point of ctd_thin: obs must not silently become the full scan set
  "obs must be fed by ctd_thin, not ctd_measurement" =
    n_obs < dbGetQuery(con, "SELECT COUNT(*) FROM ctd_measurement")[[1]])
cat(glue("obs parity: {format(n_obs, big.mark=',')} thinned rows across ",
         "{format(n_cast, big.mark=',')} casts"), "\n")
obs parity: 12,657,129 thinned rows across 18,250 casts 
Code
# --- data_stage is classified by FILENAME; verify it against CONTENT ----------
# The `_CTDBTL_` / `_CTD_` token is a convention, not a guarantee, and 2111SR is
# classified by a hardcoded cruise rule that no token supports at all. What
# actually distinguishes the tiers is whether the bottle merge has run, so test
# that directly: a `preliminary_without_bottle` cast must have NO bottle-derived
# or bottle-corrected observations, and the other two tiers must have some.
#
# This is what catches a re-organized or mis-named archive — a case the filename
# rule cannot see, and which would otherwise reach consumers as a cruise silently
# missing its salinity and oxygen.
bottle_dependent <- c(
  "salinity_ave_corr", "oxygen_ml_l_ave_sta_corr", "oxygen_umol_kg_ave_sta_corr",
  "salinity_btl", "oxygen_btl_ml_l", "btl_depth")

d_stage_content <- dbGetQuery(con, glue("
  SELECT s.data_stage,
         COUNT(DISTINCT s.sample_key)                             AS n_casts,
         COUNT(*) FILTER (WHERE o.measurement_type IN ({btl_sql})) AS n_bottle_obs
  FROM sample s LEFT JOIN obs o USING (sample_key)
  GROUP BY s.data_stage",
  btl_sql = paste0("'", bottle_dependent, "'", collapse = ", ")))

stopifnot(
  "a preliminary_without_bottle cast must carry NO bottle-merged measurement" =
    all(d_stage_content$n_bottle_obs[
      d_stage_content$data_stage == "preliminary_without_bottle"] == 0),
  "final and preliminary_with_bottle casts must carry bottle-merged values" =
    all(d_stage_content$n_bottle_obs[
      d_stage_content$data_stage %in%
        c("final", "preliminary_with_bottle")] > 0))

d_stage_content |>
  mutate(stage_label = CTD_STAGE_LABELS[data_stage]) |>
  arrange(match(data_stage, CTD_DATA_STAGES)) |>
  dt(
    caption = paste(
      "`data_stage` verified against content: bottle-merged observations are",
      "present in the two merged tiers and absent from the sensor-only tier"),
    fname = "ctd_data_stage_content"
  )
Code
# --- obs_ctd_full: supplemental full-resolution scans -----------------------
# heavy (~216M rows); set BUILD_OBS_CTD_FULL=FALSE for a fast structural render.
build_obs_ctd_full <- as.logical(Sys.getenv("BUILD_OBS_CTD_FULL", "TRUE"))
if (build_obs_ctd_full) {
  n_full <- append_obs(con, obs_tbl = "obs_ctd_full", select_sql = "
    SELECT 'env' realm, 'calcofi_ctd-cast' dataset_key,
           'calcofi_ctd-cast:cast:' || CAST(cc.cast_key AS VARCHAR) sample_key,
           cc.grid_key, cc.cruise_key, cc.latitude, cc.longitude,
           CAST(cc.datetime_start_utc AS TIMESTAMP) datetime,
           m.depth_m depth_min_m, m.depth_m depth_max_m,
           NULL::VARCHAR taxon_key, NULL::VARCHAR life_stage,
           m.measurement_type, m.measurement_value, m.measurement_qual,
           NULL::DOUBLE measurement_prec
    FROM ctd_measurement m
    JOIN ctd_cast cc ON m.ctd_cast_uuid = cc.ctd_cast_uuid")
  stopifnot(
    "obs_ctd_full must cover every scan" = n_full ==
      dbGetQuery(con, "SELECT COUNT(*) FROM ctd_measurement m
                       JOIN ctd_cast cc ON m.ctd_cast_uuid = cc.ctd_cast_uuid")[[1]])
  cat(glue("obs_ctd_full: {format(n_full, big.mark=',')} rows"), "\n")

  # Verify the bounds guard reached the SUPPLEMENTAL table too. It should — this
  # is built from the same `ctd_measurement` the guard cleaned — but "should"
  # is exactly what went unchecked: v2026.08.07 published an obs_ctd_full whose
  # `ph` ran to -2.98 (5,963 values below the declared floor) while the staged
  # ingest output for the same partition was clean. The released bytes and the
  # ingest's own bytes disagreed and nothing compared them.
  #
  # Assert rather than report: if this table ever stops deriving from the guarded
  # table, that is a silent regression across 216M published rows.
  b_full <- check_measurement_bounds(con, "obs_ctd_full", mt = d_meas_type)
  bounds_datatable(b_full)
  cat(glue("obs_ctd_full bounds: {sum(b_full$status == 'ok')} ok, ",
           "{sum(b_full$status == 'undeclared')} undeclared ",
           "(the derived _sta_corr/_cruise_corr oxygen types — Q24)"), "\n")
  stopifnot(
    "obs_ctd_full must inherit the bounds guard applied to ctd_measurement" =
      sum(b_full$status == "out_of_range") == 0)
} else {
  cat("obs_ctd_full skipped (BUILD_OBS_CTD_FULL=FALSE)\n")
}
obs_ctd_full: 259,309,891 rows 
obs_ctd_full bounds: 39 ok, 15 undeclared (the derived _sta_corr/_cruise_corr oxygen types — Q24) 

29.1 No cruise may leave obs and keep its casts

The guards above are upstream and specific — an empty read, a name with no direction letter, a cruise with no downcast. This one is downstream and general: whatever the cause, a cruise that reaches this point with casts and no observations must not be written, because write_parquet_outputs() deletes the partition that has left the data and sync_to_gcs() mirrors the deletion, so the cruise disappears from the release while its casts remain in sample. Every foreign key still resolves — FK validation runs child → parent, and a parent with no children breaks nothing — which is why v2026.08.08 published the loss.

The correct count for this dataset is zero, so it is asserted rather than ratcheted. sample legitimately carries about half its cast rows without observations (one row per cast per direction, against one direction kept in obs), which is why the grain is the cruise and not the sample.

Code
# `d_cruise_cov`, never `cov` — that shadows stats::cov for the rest of the
# render, and a short name is exactly how the release chunk clobbered its own
# `d_cov` and died after a 50-minute freeze.
d_cruise_cov <- check_cruise_coverage(con, max_orphan_cruises = 0L)

d_cruise_cov |>
  dt(
    caption = "Cruise coverage: cruises carrying samples with no observations",
    fname   = "ctd_cruise_coverage")

30 Write Parquet

Code
# collect mismatches for manifest
mismatches <- list(
  ships             = collect_ship_mismatches(con, "ctd_cast"),
  measurement_types = collect_measurement_type_mismatches(
    con, here("metadata/measurement_type.csv")),
  cruise_keys       = collect_cruise_key_mismatches(con, "ctd_cast"))

# core shards + the supplemental outputs this dataset owns. ctd_cast/ctd_thin/
# ctd_measurement/ctd_summary are superseded by sample/obs/obs_ctd_full, and
# ctd_wide is retired (ERDDAP reads DuckDB views + netCDF now).
tbls_out <- core_output_tables(
  con, extra = c("obs_ctd_full", "measurement_type"))
parquet_stats <- write_parquet_outputs(
  con          = con,
  output_dir   = dir_parquet,
  tables       = tbls_out,
  partition_by = list(
    obs          = "cruise_key",
    obs_ctd_full = "cruise_key"),
  # Sort key MUST match `core_sort` in release_database.qmd, so the shard the
  # release reads is already clustered the way it re-exports it. Two parts
  # matter and both were measured, not guessed:
  #   grid_key FIRST co-locates every column that is a function of the cast —
  #     sample_key, hex_id, latitude, longitude, datetime — which is most of the
  #     row width. measurement_type-first clusters one 54-value column and
  #     scatters the rest: 4.61 GB vs 1.22 GB for obs_ctd_full.
  #   datetime LAST is not decoration. (grid_key, depth_min_m, measurement_type)
  #     leaves large tie groups, and rows inside a tie land in arbitrary order,
  #     scattering lat/lon/datetime again. Adding the tiebreak made a partition
  #     27.55 -> 20.20 MB (CTD) and 23.22 -> 16.95 MB (mets), i.e. ~27% below
  #     what the release's own re-export produced. Do not drop it.
  sort_by = list(
    obs          = c("measurement_type", "depth_min_m"),
    obs_ctd_full = c("grid_key", "depth_min_m", "measurement_type", "datetime"),
    sample       = "hilbert:longitude,latitude"),
  strip_provenance = FALSE,
  mismatches       = mismatches,
  supplemental     = c("obs_ctd_full")
)

parquet_stats |>
  dt(fname = "ctd_parquet_stats")

31 Write Metadata JSON

Code
metadata_path <- build_metadata_json(
  con                  = con,
  d_tbls_rd            = d_tbls_rd,
  d_flds_rd            = d_flds_rd,
  metadata_derived_csv = c(here("metadata/core_dictionary.csv"),
                           glue("{dir_meta}/metadata_derived.csv")),
  output_dir           = dir_parquet,
  tables               = tbls_out,
  provider             = provider,
  dataset              = dataset,
  workflow_url         = cc$workflow_url,
  tables_owned         = tables_owned
)
metadata.json documentation gaps (4 tables, 68 columns) — these render blank in cc_describe_table() / cc_db_catalog():
columns with no description_md: 24    measurement_type.valid_min, measurement_type.valid_max, measurement_type.valid_depth_min_m, measurement_type.valid_depth_max_m, measurement_type.derivation, measurement_type.grain, obs_ctd_full.obs_id, obs_ctd_full.realm, obs_ctd_full.dataset_key, obs_ctd_full.sample_key, obs_ctd_full.grid_key, obs_ctd_full.cruise_key (+12 more)
measurement columns with no units: 8    measurement_type.valid_min, measurement_type.valid_max, measurement_type.valid_depth_min_m, measurement_type.valid_depth_max_m, measurement_type.derivation, obs_ctd_full.depth_min_m, obs_ctd_full.depth_max_m, sample.data_stage
  backfill via metadata/{provider}/{dataset}/flds_redefine.csv, then re-run
Code
# write relationships.json sidecar with PKs/FKs
build_relationships_json(
  rels       = core_relationships(tbls_out),
  output_dir = dir_parquet,
  provider   = provider,
  dataset    = dataset
)
[1] "/Users/bbest/Github/CalCOFI/workflows/data/parquet/calcofi_ctd-cast/relationships.json"

Both sidecars are read back from disk rather than from the objects the chunk above returned, and both viewers always evaluate. They describe files that exist in either mode, so gating them on the heavy path would have silently dropped two of this notebook’s published outputs from a fast render — which is exactly the kind of quiet degradation a skip path must not introduce.

Code
path_meta_json <- file.path(dir_parquet, "metadata.json")
if (file_exists(path_meta_json)) {
  listviewer::jsonedit(
    jsonlite::fromJSON(path_meta_json, simplifyVector = FALSE), mode = "view")
} else {
  say(glue("metadata.json not found at {path_meta_json}"))
}
Code
path_rels_json <- file.path(dir_parquet, "relationships.json")
if (file_exists(path_rels_json)) {
  listviewer::jsonedit(
    jsonlite::fromJSON(path_rels_json, simplifyVector = FALSE), mode = "view")
} else {
  say(glue("relationships.json not found at {path_rels_json}"))
}

32 Export Modified Dependency Deltas

Code
# export _new delta sidecars for modified dependency tables (from calcofi.modifies)
# these are NOT in the manifest — picked up by build_release_table_registry()
for (tbl in modifies_tables) {
  pk_col   <- modifies_pks[[tbl]]$pk_col
  keys_old <- modifies_pks[[tbl]]$keys
  keys_new <- dbGetQuery(con, glue("SELECT {pk_col} FROM {tbl}"))[[1]]
  additions <- setdiff(keys_new, keys_old)

  if (length(additions) > 0) {
    pq_path <- file.path(dir_stage, paste0(tbl, "_new.parquet"))
    vals <- paste(shQuote(additions, "sh"), collapse = ", ")
    export_parquet(con,
      glue("SELECT * FROM {tbl} WHERE {pk_col} IN ({vals})"), pq_path)
    say(glue("{length(additions)} new {tbl} row(s) -> {tbl}_new.parquet"))
  } else {
    say(glue("No new {tbl} rows — {tbl}_new not exported"))
  }
}
No new ship rows — ship_new not exported

33 Upload to GCS

Code
# always re-enable eval for upload and cleanup
knitr::opts_chunk$set(eval = TRUE)
Code
# Record the fingerprint as soon as the OUTPUTS are complete — after parquet,
# metadata and the delta sidecars, and BEFORE the upload.
#
# It used to be written last, after the GCS sync and the appendices, to avoid
# claiming "these inputs produced those outputs" for a run that died mid-pivot.
# That reasoning is right; the placement was too conservative. The outputs are
# finished HERE, and the upload is a separate concern that can fail for reasons
# having nothing to do with them — as it did: a 2 h 45 m run wrote every table
# correctly, then lost one object of a 3.2 GB transfer to a slow link, and threw
# away the whole rebuild because the fingerprint had not been reached yet. The
# identical upload succeeded by hand a minute later.
#
# Writing it here is still honest, because `parquet_ok` in [check_resume] does
# not take this file's word for anything: it independently verifies that every
# non-supplemental table named in the manifest is actually on disk. And the sync
# is no longer gated on the rebuild, so a fingerprint written before a failed
# upload cannot leave the mirror stale.
if (!parquet_complete) {
  write_input_fingerprint(fingerprint_path, fp)
  say(glue("Recorded input fingerprint {substr(fp$hash, 1, 12)} → {fingerprint_path}"))
}
Recorded input fingerprint eda0b5de0153 → /Users/bbest/Github/CalCOFI/workflows/data/wrangling/calcofi_ctd-cast_inputs.json
Code
# ALWAYS sync, even when the parquet was unchanged.
#
# This used to be gated on `!parquet_complete`, which coupled two independent
# facts: "the outputs were rebuilt" and "GCS matches the outputs". A run that
# rebuilt everything and then lost the upload to a network blip left GCS stale,
# and the *next* run — seeing complete parquet and an unchanged fingerprint —
# skipped the sync too, so the mirror stayed stale indefinitely with nothing
# reporting it.
#
# rsync is the right shape for this: it compares before it transfers, so a sync
# over an unchanged directory is a listing and no bytes. Paying that on every
# render is far cheaper than a silently stale mirror, and it makes the upload
# idempotent — safe to re-run after any failure.
sync_to_gcs(
  local_dir    = dir_stage,
  sidecar_dir  = dir_parquet,
  gcs_prefix   = glue("ingest/{dir_label}"),
  bucket       = "calcofi-db",
  delete_stale = TRUE
)
# A tibble: 21,339 × 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
# ℹ 21,329 more rows

34 Findings

What this ingest just published, and the condition it is in. The sections above are the build; this is the report — the place a reader should be able to learn what is known to be wrong with CalCOFI CTD data without opening an app, a rule file or a release.

Three things make it worth having here rather than only in ctd-qaqc:

  • it reads the parquet that was just written, not the wrangling database, so it is checking what shipped rather than what was in memory before shipping;
  • it runs the same rule registry (metadata/qc_rules/) through the same engine (calcofi4db::qc_run_all()) that the app runs, so a number here and a number there cannot disagree;
  • it renders on a fast re-run (see Check for Resumable State), so the narrative can be revised without an hour of recomputation.

It sits after the GCS upload deliberately. Everything above is the ingest’s actual product and is already delivered by the time this runs; a broken diagnostic must not be able to discard it — the lesson the Gantt appendix records.

Code
# in-memory DuckDB with VIEWS over this run's parquet. Views cost nothing until
# queried, and reading the outputs rather than the wrangling tables is what makes
# this section render identically on a full run and on a fast re-run.
fcon <- get_duckdb_con(":memory:")
dbExecute(fcon, "SET threads TO 4")
[1] 0
Code
pq_view <- function(tbl, path) {
  if (!file_exists(path) && !dir_exists(path)) return(FALSE)
  src <- if (dir_exists(path))
    glue("read_parquet('{path}/**/*.parquet', hive_partitioning = true)") else
      glue("read_parquet('{path}')")
  dbExecute(fcon, glue("CREATE OR REPLACE VIEW {tbl} AS SELECT * FROM {src}"))
  TRUE
}
has_obs  <- pq_view("obs",          file.path(dir_stage, "obs"))
has_smp  <- pq_view("sample",       file.path(dir_stage, "sample.parquet"))
has_full <- pq_view("obs_ctd_full", file.path(dir_stage, "obs_ctd_full"))
stopifnot("no obs/sample parquet to report on" = has_obs && has_smp)

# Reference inputs the rules join against — the quality vocabulary, the harmonic
# climatology and station bottom depths mined from the Access master, and a
# seafloor depth per cast from the GEBCO crop that apps/ctd-viz already commits.
# None of it is part of the release. A missing input is left as a MISSING TABLE so
# its rules ERROR rather than returning zero rows and reading as clean.
gebco_tif <- normalizePath(
  file.path(here(), "..", "apps/ctd-viz/data/gebco_calcofi.tif"), mustWork = FALSE)
say("QC reference data staged:")
QC reference data staged:
Code
qc_ref <- qc_stage_reference(fcon, here(), gebco_tif = gebco_tif)
  measurement_type       200 rows (92 with a declared range)
  measurement_qual       15 codes
  climatology_harmonic   5,880 rows
  station                75 rows
  standard_depth         14 rows
  station_class          7 rows
  mld_sigma              620 rows
  nutclinedepth          585 rows
  sample_seafloor        18,250 casts (18,250 with a depth)
Code
f_head <- dbGetQuery(fcon, "
  SELECT COUNT(*)                                       AS n_obs,
         COUNT(DISTINCT sample_key)                     AS n_casts,
         COUNT(DISTINCT cruise_key)                     AS n_cruises,
         COUNT(DISTINCT measurement_type)               AS n_types,
         CAST(MIN(datetime) AS DATE)                    AS d0,
         CAST(MAX(datetime) AS DATE)                    AS d1,
         COUNT(*) FILTER (WHERE measurement_value = -99) AS n_neg99
  FROM obs")
n_smp  <- dbGetQuery(fcon, "SELECT COUNT(*) AS n FROM sample")$n
f_full <- if (has_full) dbGetQuery(fcon,
  "SELECT COUNT(*) AS n, COUNT(*) FILTER (WHERE measurement_value = -99) AS n_neg99
   FROM obs_ctd_full") else NULL

say(glue("obs           : {format(f_head$n_obs, big.mark = ',')} rows, ",
         "{f_head$n_types} measurement types, ",
         "{format(f_head$n_casts, big.mark = ',')} casts, ",
         "{f_head$n_cruises} cruises, {f_head$d0} to {f_head$d1}"))
obs           : 12,657,129 rows, 33 measurement types, 9,133 casts, 128 cruises, 1992-04-18 to 2026-07-13
Code
say(glue("sample        : {format(n_smp, big.mark = ',')} cast events"))
sample        : 18,250 cast events
Code
if (!is.null(f_full))
  say(glue("obs_ctd_full  : {format(f_full$n, big.mark = ',')} rows (supplemental)"))
obs_ctd_full  : 259,309,891 rows (supplemental)

34.1 The -99 sentinel — fixed at the source, and now guarded

-99 is this source’s documented missing marker. The notebook had always known that — pseudoNA_values <- c(-9.99e-29, -99) strips it from longitude and latitude — but the rule was never extended to the measurement columns, so -99 flowed through ctd_measurementctd_thinobs as though it were a reading. Release v2026.07.17 shipped 84,302 of them, including canonical oxygen (oxygen_ml_l_ave_sta_corr 953, oxygen_umol_kg_ave_sta_corr 4,294) alongside isus_v 40,479, ph 31,493 and spar 6,189.

The NOT isnan / isfinite guard in the pivot could never catch it: -99 is a perfectly finite double. A -99 mL/L oxygen silently corrupts any mean, minimum or anomaly a consumer computes, which is why the pivot now deletes the rows rather than reporting them — a missing value has no business existing as a row in long format.

The count below is an assertion, not a statistic: it is the regression guard for that fix, and the ctd_sentinel_neg99 rule is the same test run from the registry.

Code
say(glue("-99 in obs          : {f_head$n_neg99}   (was 84,302 in v2026.07.17)"))
-99 in obs          : 0   (was 84,302 in v2026.07.17)
Code
if (!is.null(f_full))
  say(glue("-99 in obs_ctd_full : {f_full$n_neg99}"))
-99 in obs_ctd_full : 0
Code
stopifnot(
  "the -99 sentinel is back in obs — the pivot fix has regressed" =
    f_head$n_neg99 == 0,
  "the -99 sentinel is back in obs_ctd_full" =
    is.null(f_full) || f_full$n_neg99 == 0)

34.2 btl_* promoted to canonical — and the depth-thinning trap

The CTD cast files ship the bottle values taken on the same cast: nutrients, chlorophyll, and the lab references (BTL_Temp, SaltB, OxB) that sensor calibration is judged against. Eleven of those twelve types were registered but flagged is_canonical = FALSE, so they were excluded from ctd_thin and therefore from obs — only btl_ammonium was canonical, which read like a stray edit rather than a decision. Making the rest canonical is what enables the bottle-vs-sensor calibration rules below; without the reference side, those rules have nothing to compare and (correctly) report skip, not pass.

ImportantFlagging them canonical was necessary but not sufficient

Thinning is designed for dense sensor scans: the ~10 m backbone and the RDP inflections are both derived from the temperature/salinity profile, so a depth is kept because the sensor profile bends there. Bottle values are not a profile — they are a handful of discrete lab samples at depths chosen by the watch, and nothing about the sensor profile knows where they are.

Selecting depths by sensor geometry and then subsetting bottle values to those depths silently discards most of them. Measured against v2026.07.17: of 133,206 distinct (cast, depth) bottle ammonium measurements, only 35,549 (26.7%) sat at a retained depth. So promoting the types alone would have landed about a quarter of the bottle record in obs and looked like a success.

ctd_thin therefore keeps a third retention reason — bottle — that retains every depth carrying a canonical bottle-grain value. The table below verifies it: within the cast direction obs covers, retention must be 100%.

Code
# bottle-grain types carry the marker as a prefix (btl_ammonium), a suffix
# (salinity_btl) or an infix (oxygen_btl_ml_l) — three positions, hence two LIKEs
btl_where <- "(measurement_type LIKE 'btl\\_%' ESCAPE '\\'
               OR measurement_type LIKE '%\\_btl%' ESCAPE '\\')"

f_btl <- dbGetQuery(fcon, glue("
  SELECT COUNT(*) FILTER (WHERE {btl_where}) AS n_btl, COUNT(*) AS n FROM obs"))
say(glue("bottle-grain rows in obs: {format(f_btl$n_btl, big.mark = ',')} of ",
         "{format(f_btl$n, big.mark = ',')} ",
         "({round(100 * f_btl$n_btl / f_btl$n, 1)}%)"))
bottle-grain rows in obs: 1,489,181 of 12,657,129 (11.8%)
Code
# retention WITHIN the direction obs keeps. The other direction is not loss: the
# source duplicates every bottle value onto both cast records (question 05), so
# comparing against the full scan set would report a 50% "loss" that is really the
# duplicate copy being dropped.
if (has_full) {
  f_ret <- dbGetQuery(fcon, glue("
    WITH chosen AS (SELECT DISTINCT sample_key FROM obs),
    f AS (SELECT DISTINCT sample_key, depth_min_m, measurement_type
          FROM obs_ctd_full
          WHERE {btl_where} AND sample_key IN (SELECT sample_key FROM chosen)),
    t AS (SELECT DISTINCT sample_key, depth_min_m, measurement_type
          FROM obs WHERE {btl_where})
    SELECT COUNT(*) AS n_avail,
           COUNT(*) FILTER (WHERE t.sample_key IS NOT NULL) AS n_kept
    FROM f LEFT JOIN t USING (sample_key, depth_min_m, measurement_type)"))
  say(glue("bottle values retained by thinning: ",
           "{format(f_ret$n_kept, big.mark = ',')} of ",
           "{format(f_ret$n_avail, big.mark = ',')} on the chosen direction ",
           "({round(100 * f_ret$n_kept / f_ret$n_avail, 1)}%)"))
  stopifnot(
    "depth thinning is dropping bottle values again (retained_reason = 'bottle')" =
      f_ret$n_kept == f_ret$n_avail)
}
bottle values retained by thinning: 1,475,080 of 1,475,080 on the chosen direction (100%)
Code
dbGetQuery(fcon, glue("
  SELECT measurement_type, COUNT(*) AS n,
         COUNT(DISTINCT sample_key) AS n_casts,
         ROUND(MIN(measurement_value), 3) AS v_min,
         ROUND(MAX(measurement_value), 3) AS v_max
  FROM obs WHERE {btl_where} GROUP BY 1 ORDER BY n DESC")) |>
  datatable(
    caption  = paste("Bottle-grain measurement types now carried in the default",
                     "obs — the reference side of every calibration check"),
    rownames = FALSE,
    options  = list(pageLength = 12, scrollX = TRUE, dom = "tip"))

34.3 One direction per physical cast: half of sample carries no obs

sample is one row per cast_key, and cast_key carries the direction suffix (…d / …u) — so a station occupation that recorded both a down- and an upcast contributes two sample rows. obs carries ctd_thin, which by design keeps one direction per physical cast. The consequence is arithmetic, but it is not stated anywhere a consumer would look: roughly half of the CTD rows in the release’s sample have no observations attached to them in the default tier.

That is not data loss — the other direction is in the supplemental obs_ctd_full — but a consumer counting casts from sample and a consumer counting casts from obs will get answers that differ by about a factor of two, and neither is wrong. Which direction should be authoritative is question calcofi_ctd-cast_06.

Code
f_dir <- dbGetQuery(fcon, "
  SELECT COUNT(*) AS n_sample,
         COUNT(*) FILTER (
           WHERE sample_key IN (SELECT DISTINCT sample_key FROM obs)) AS n_with_obs
  FROM sample")
say(glue("sample rows                 : {format(f_dir$n_sample, big.mark = ',')}"))
sample rows                 : 18,250
Code
say(glue("  with any obs (default tier): {format(f_dir$n_with_obs, big.mark = ',')} ",
         "({round(100 * f_dir$n_with_obs / f_dir$n_sample, 1)}%)"))
  with any obs (default tier): 9,133 (50%)
Code
say(glue("  no obs (other direction)   : ",
         "{format(f_dir$n_sample - f_dir$n_with_obs, big.mark = ',')}"))
  no obs (other direction)   : 9,117

34.4 Quality codes, as published

measurement_qual was a verbatim pass-through for years — carried through every table, never interpreted, with no vocabulary anywhere in the repo. There is one now: metadata/measurement_qual.csv, recovered from the CalCOFI hydrographic master’s own field documentation (6 = data OK but taken from CTD, 8 = value is suspect, 9 = missing data) and confirmed to apply to CTD, whose flags use the same codes. The codes were also being stored as "9.0" by a double→string cast, so none of them matched the vocabulary; stripping that textually (not via an INTEGER cast, which would round an unexpected "9.5") turned 3,860 spurious “unrecognized code” hits into zero.

Two things below are worth a reader’s attention, and both are open with the providers rather than acted on here:

  1. The headline variables carry no flags at all. Source flags attach to the component sensors (Temp1Q, Salt1Q, Ox1Q, Ox2Q) while the canonical types are the averages, so the quality information is lost in the mean. A consumer cannot currently tell whether a headline CTD value is trustworthy (question calcofi_ctd-cast_09).
  2. Every row flagged 9 = “missing data” carries a value — including physically impossible ones. Either the flag or the value is wrong, and nothing in the source says which (question calcofi_ctd-cast_13).
Code
f_qual <- dbGetQuery(fcon, "
  SELECT o.measurement_qual AS code, q.label, q.description,
         COUNT(*) AS n,
         COUNT(o.measurement_value)              AS n_with_value,
         COUNT(DISTINCT o.measurement_type)      AS n_types,
         ROUND(MIN(o.measurement_value), 3)      AS v_min,
         ROUND(MAX(o.measurement_value), 3)      AS v_max
  FROM obs o LEFT JOIN measurement_qual q ON o.measurement_qual = q.qual_code
  WHERE o.measurement_qual IS NOT NULL
  GROUP BY 1, 2, 3 ORDER BY n DESC")

n_typed <- dbGetQuery(fcon, "
  SELECT COUNT(DISTINCT measurement_type) AS n_all,
         COUNT(DISTINCT measurement_type) FILTER (
           WHERE measurement_qual IS NOT NULL) AS n_flagged FROM obs")

say(glue("flagged obs rows       : {format(sum(f_qual$n), big.mark = ',')} of ",
         "{format(f_head$n_obs, big.mark = ',')} ",
         "({round(100 * sum(f_qual$n) / f_head$n_obs, 3)}%)"))
flagged obs rows       : 12,276 of 12,657,129 (0.097%)
Code
say(glue("measurement types with any flag: {n_typed$n_flagged} of {n_typed$n_all}"))
measurement types with any flag: 12 of 33
Code
n_missing_flag <- sum(f_qual$n_with_value[f_qual$code == "9"])
if (length(n_missing_flag) && n_missing_flag > 0)
  say(glue("rows flagged 9 = 'missing data' that nonetheless carry a value: ",
           "{format(n_missing_flag, big.mark = ',')}"))
rows flagged 9 = 'missing data' that nonetheless carry a value: 6,760
Code
f_qual |>
  mutate(label = coalesce(label, "UNRECOGNIZED")) |>
  datatable(
    caption  = paste("Quality codes in the published obs, decoded against",
                     "metadata/measurement_qual.csv"),
    rownames = FALSE,
    options  = list(pageLength = 10, scrollX = TRUE, dom = "tip")) |>
  formatStyle("label",
              color = styleEqual("UNRECOGNIZED", "#d03b3b", default = "inherit"))

34.5 What the source documentation says about these numbers

Each source zip ships its own documentation (CTD-CSV-Format.pdf, CTD Data Files.pdf, FilenamingAndAscHeaderNotes.txt, and a PreliminaryDataReadme.txt on preliminary cruises). Combed 2026-08-01; the full account is in the QA/QC protocol. Four points change how a consumer should read what this ingest publishes.

The canonical types are the providers’ own recommendation, not our preference. Every property is published three times — SBE-processed, _CruiseCorr (one regression per cruise, from 4-second averages against ~1400 bottles) and _StaCorr (a regression per cast, from 1 m bin averages against that cast’s ~20 bottles). The source states station-corrected data are the best, particularly for nitrate, which is why *_sta_corr are the types flagged canonical here. Salinity is corrected separately, from bottle salinities below 350 m, applied at all depths.

A missing *_sta_corr is often correct. Station correction needs a 500 m cast with roughly ten or more bottles. On winter and spring cruises, lines north of 76.7 may be occupied with as few as 0–12 bottles and receive cruise-corrected values only. Counting nulls as defects will mis-read a whole class of casts.

Values are 1 m bin averages, not raw scans. obs_ctd_full is full published resolution. That is also why the loop-edit rule’s 1 m threshold is a noise floor rather than a tolerance.

Notedata_stage now reaches the release

The source is explicit that preliminary data are for non-publication use, and specific about what moves: “Temperature and salinities may change very little but oxygen, nitrate and chlorophyll data may change significantly after post-cruise calibrations.”

Until v2026.08 the released sample carried no such column, so a consumer could not tell a final cruise from a preliminary one and the caveat above could not reach them — question calcofi_ctd-cast_14. Core sample now has data_stage, supplied by this ingest as the optional trailing column of append_sample() (calcofi4db 3.4.0). The other 15 ingests, none of which draw the distinction, leave it NULL rather than having to change.

It carries three values, not two, matching the provider’s own published progression — finalpreliminary_with_bottlepreliminary_without_bottle. The split matters most for exactly the quantities the caveat names: oxygen and chlorophyll are bottle-corrected, so a preliminary_without_bottle cruise has not merely provisional values for them but none at all. Collapsing both preliminary tiers into one label made that indistinguishable from a cruise whose sensors failed.

Code
# how much of what we publish is preliminary? Read from the wrangling DB when the
# heavy path ran; otherwise say so rather than guessing.
if ("ctd_cast" %in% DBI::dbListTables(con)) {
  dbGetQuery(con, "
    SELECT data_stage, COUNT(DISTINCT cruise_key) AS n_cruises,
           COUNT(DISTINCT cast_key) AS n_casts
    FROM ctd_cast GROUP BY 1") |>
    # ordered by processing stage, not by size: the point of the table is where a
    # cruise sits in the progression
    arrange(match(data_stage, CTD_DATA_STAGES)) |>
    mutate(stage_label = CTD_STAGE_LABELS[data_stage], .after = data_stage) |>
    datatable(
      caption = paste("Processing stage in this ingest — carried into the",
                      "release on `sample.data_stage`"),
      rownames = FALSE, options = list(dom = "t"))
} else {
  say(paste("data_stage not available on a fast re-render (ctd_cast is not",
            "materialized); it is charted in the Gantt appendix on a full run."))
}

34.6 QC rules over this ingest’s output

The rules are data, not code: metadata/qc_rules/rules.csv plus one sql/*.sql file per rule, versioned with the pipeline that produces the data they check, so a data manager can review one in a diff. They are executed here by calcofi4db::qc_run_all() — the same engine apps/ctd-qaqc uses, moved into the package once it had two callers so the app and this notebook cannot report different numbers for the same rule.

Every rule targets obs / sample, never a per-dataset table. That is what lets the identical registry run against a release, against this parquet, and (next) against an uploaded file.

Noteskip is not pass

A rule whose input is absent returns zero rows, which is indistinguishable from “the data is clean”. The engine therefore reports skip with a reason, never a silent pass — this is not hypothetical: the three bottle-vs-sensor rules did exactly that against v2026.07.30, which predates the btl_* promotion above. Rules scoped to a single cruise (they read the full-resolution obs_ctd_full) skip here too, and are run separately below.

Code
dir_rules <- here("metadata/qc_rules")
rules_all <- qc_read_rules(dir_rules, active_only = FALSE)
rules_act <- filter(rules_all, active)
rules_now <- filter(rules_act, coalesce(scope, "all") == "all")

f_types <- qc_present_types(fcon, "calcofi_ctd-cast")
qc_res  <- qc_run_all(fcon, rules_now, limit = 500L, present_types = f_types)
qc_sum  <- qc_summarize(qc_res, rules_now)

say(glue("rules in registry : {nrow(rules_all)} ",
         "({nrow(rules_act)} active, {nrow(rules_all) - nrow(rules_act)} parked)"))
rules in registry : 22 (19 active, 3 parked)
Code
say(glue("run here          : {nrow(rules_now)} release-wide ",
         "(+{nrow(rules_act) - nrow(rules_now)} cruise-scoped, below)"))
run here          : 14 release-wide (+5 cruise-scoped, below)
Code
for (s in c("FAIL", "ERROR", "flag", "skip", "pass")) {
  n <- sum(qc_sum$status == s)
  if (n > 0) say(glue("  {format(s, width = 6)}: {n}"))
}
  flag  : 9
  pass  : 5
Code
say(glue("total findings    : ",
         "{format(sum(qc_sum$n, na.rm = TRUE), big.mark = ',')}"))
total findings    : 35,911
Code
qc_sum |>
  transmute(rule_key, status, findings = n, severity, type = rule_type, target,
            description, `sec` = elapsed_s, note) |>
  datatable(
    caption  = paste("QC rule results over this ingest's parquet —",
                     "registry metadata/qc_rules/, engine calcofi4db::qc_run_all()"),
    rownames = FALSE,
    options  = list(pageLength = 15, scrollX = TRUE, dom = "tip")) |>
  formatStyle(
    "status",
    color = styleEqual(c("pass", "flag", "FAIL", "ERROR", "skip"),
                       c("#1a7f37", "#b06000", "#d03b3b", "#d03b3b", "#6e7781")),
    fontWeight = styleEqual(c("FAIL", "ERROR"), c("bold", "bold"),
                            default = "normal"))

34.6.1 Values outside their declared range

Ranges live in metadata/measurement_type.csv (valid_min / valid_max), moved there from an inline tribble so they are reviewable in a diff, emittable by the CF netCDF writer as real variable attributes, and usable by any rule rather than only by this notebook. They are deliberately generous: they catch values that cannot be real, they do not police oceanography. Nothing below is dropped.

Code
f_range <- dbGetQuery(fcon, "
  SELECT o.measurement_type,
         COUNT(*)                                    AS n_bad,
         COUNT(DISTINCT o.cruise_key)                AS n_cruises,
         ROUND(MIN(o.measurement_value), 3)          AS v_min,
         ROUND(MAX(o.measurement_value), 3)          AS v_max,
         m.valid_min, m.valid_max
  FROM obs o JOIN measurement_type m USING (measurement_type)
  WHERE m.valid_min IS NOT NULL
    AND (o.measurement_value < m.valid_min OR o.measurement_value > m.valid_max)
  GROUP BY 1, 6, 7 ORDER BY n_bad DESC")

f_range |>
  left_join(
    dbGetQuery(fcon, "SELECT measurement_type, COUNT(*) AS n_total FROM obs GROUP BY 1"),
    by = "measurement_type") |>
  transmute(measurement_type, n_bad, n_total,
            `% bad` = round(100 * n_bad / n_total, 2),
            n_cruises, v_min, v_max,
            range = glue("{valid_min}..{valid_max}")) |>
  datatable(
    caption  = "Published values outside their declared plausible range",
    rownames = FALSE,
    options  = list(pageLength = 15, scrollX = TRUE, dom = "tip"))

34.6.2 Impossible temperatures

The single finding most worth a provider’s time. temperature_ave reaches 60.4 °C in the published product, and this is not a sensor warming on deck: most of the values above the 40 °C ceiling are at depth, they span more than two decades and more than one ship, and where a bottle thermometer sampled the same cast at the same depth it disagrees by 30–40 °C. Three independent lines — the range rule, the depth distribution, and the bottle reference — say the same thing, which is why this is stated as a finding rather than a suspicion.

Code
f_t <- dbGetQuery(fcon, "
  SELECT COUNT(*) FILTER (WHERE measurement_value > 40)                       AS n_hot,
         COUNT(*) FILTER (WHERE measurement_value > 40 AND depth_min_m > 2)   AS n_hot_deep,
         COUNT(*) FILTER (WHERE measurement_value < -2)                       AS n_cold,
         COUNT(DISTINCT cruise_key) FILTER (
           WHERE measurement_value > 40 OR measurement_value < -2)            AS n_cruises
  FROM obs WHERE measurement_type = 'temperature_ave'")
say(glue("temperature_ave > 40 degC : {f_t$n_hot} ",
         "({f_t$n_hot_deep} of them deeper than 2 m — not a deck warm-up)"))
temperature_ave > 40 degC : 0 (0 of them deeper than 2 m — not a deck warm-up)
Code
say(glue("temperature_ave < -2 degC : {f_t$n_cold}"))
temperature_ave < -2 degC : 0
Code
say(glue("cruises affected          : {f_t$n_cruises}"))
cruises affected          : 0
Code
dbGetQuery(fcon, "
  SELECT cruise_key,
         COUNT(*)                             AS n,
         COUNT(DISTINCT sample_key)           AS n_casts,
         ROUND(MIN(depth_min_m), 1)           AS depth_min,
         ROUND(MAX(depth_min_m), 1)           AS depth_max,
         ROUND(MIN(measurement_value), 2)     AS t_min,
         ROUND(MAX(measurement_value), 2)     AS t_max
  FROM obs
  WHERE measurement_type = 'temperature_ave'
    AND (measurement_value > 40 OR measurement_value < -2)
  GROUP BY 1 ORDER BY n DESC") |>
  datatable(
    caption  = paste("Physically impossible temperature_ave by cruise —",
                     "the table to send with question calcofi_ctd-cast_02"),
    rownames = FALSE,
    options  = list(pageLength = 15, scrollX = TRUE, dom = "tip"))
Code
# the corroboration: where a bottle thermometer sampled the same cast at the same
# depth, what did IT read? This is only possible because btl_temperature is now
# canonical (above) — before that the reference side was not in obs at all.
f_tbtl <- dbGetQuery(fcon, "
  WITH hot AS (
    SELECT sample_key, cruise_key, depth_min_m, measurement_value AS v_sensor
    FROM obs
    WHERE measurement_type = 'temperature_ave' AND measurement_value > 40)
  SELECT h.cruise_key,
         COUNT(*)                              AS n_pairs,
         ROUND(MIN(h.depth_min_m), 1)          AS depth_min,
         ROUND(MAX(h.depth_min_m), 1)          AS depth_max,
         ROUND(MIN(b.measurement_value), 2)    AS bottle_min,
         ROUND(MAX(b.measurement_value), 2)    AS bottle_max,
         ROUND(MIN(h.v_sensor), 2)             AS sensor_min,
         ROUND(MAX(h.v_sensor), 2)             AS sensor_max
  FROM hot h
  JOIN obs b ON b.sample_key = h.sample_key AND b.depth_min_m = h.depth_min_m
            AND b.measurement_type = 'btl_temperature'
  GROUP BY 1 ORDER BY n_pairs DESC")

if (nrow(f_tbtl)) {
  datatable(
    f_tbtl,
    caption  = paste("Bottle thermometer vs sensor on the same cast and depth,",
                     "where the sensor reads above 40 degC"),
    rownames = FALSE,
    options  = list(pageLength = 10, scrollX = TRUE, dom = "tip"))
} else {
  say("no bottle temperature at the same cast+depth as any >40 degC sensor value")
}
no bottle temperature at the same cast+depth as any >40 degC sensor value

34.6.3 Negative pH, clustered by cruise

Not previously reported, and the largest single range violation in the release: ph accounts for the great majority of out-of-range values, and the bad values are not scattered — they cluster tightly within a cruise, often thousands of rows sitting in a narrow band around −2.5 to −3. Scattered impossible values look like bad scans; a whole cruise pinned at −2.7 looks like a column that was never converted from sensor voltage to pH units. The dataset has two explicit voltage columns (isus_v, fluorescence_v) but no ph_v twin, so a raw-voltage ph would have nowhere else to go. That is a hypothesis for the providers, not a conclusion (question calcofi_ctd-cast_12).

Code
dbGetQuery(fcon, "
  SELECT cruise_key,
         COUNT(*)                                                        AS n_bad,
         ROUND(MIN(measurement_value), 2)                                AS v_min,
         ROUND(MAX(measurement_value), 2)                                AS v_max,
         COUNT(*) FILTER (WHERE measurement_value < 0)                   AS n_negative,
         ROUND(MAX(measurement_value) - MIN(measurement_value), 2)       AS spread
  FROM obs
  WHERE measurement_type = 'ph' AND (measurement_value < 6 OR measurement_value > 9)
  GROUP BY 1 ORDER BY n_bad DESC") |>
  datatable(
    caption  = paste("Out-of-range pH by cruise — a narrow `spread` on thousands",
                     "of rows is the signature of an unconverted column"),
    rownames = FALSE,
    options  = list(pageLength = 10, scrollX = TRUE, dom = "tip"))

34.6.4 Full-resolution profile rules, one cruise at a time

Spikes, loop edits and up/down disagreement are invisible in obs: thinning keeps one direction and roughly one sample per 10 m, which is exactly the structure these rules examine. They read the supplemental obs_ctd_full instead, which is hive-partitioned by cruise_key so a scoped query prunes to one cruise rather than scanning 212M rows.

The cruise below is chosen by evidence, not by recency: it is the one with the most out-of-range canonical values, so the profile rules run where there is most reason to look. This is a sample, not a sweep — the remaining cruises are the app’s job.

Code
rules_cruise <- filter(rules_act, coalesce(scope, "all") == "cruise")

if (has_full && nrow(rules_cruise)) {
  worst_cruise <- dbGetQuery(fcon, "
    SELECT o.cruise_key, COUNT(*) AS n
    FROM obs o JOIN measurement_type m USING (measurement_type)
    WHERE m.valid_min IS NOT NULL
      AND (o.measurement_value < m.valid_min OR o.measurement_value > m.valid_max)
    GROUP BY 1 ORDER BY n DESC LIMIT 1")$cruise_key

  # Since the two-sensor average repair and the bounds guard landed, there is
  # routinely NO out-of-range cruise — the query returns zero rows and
  # `$cruise_key` is character(0). That is the good outcome, and it used to
  # abort the render: character(0) reached qc_run_rule()'s scope guard, where
  # `&&` on a zero-length operand yields NA and `if (NA)` stops the chunk.
  #
  # "Nothing is out of range" is not a reason to skip the profile rules. They
  # test spike, gradient, density inversion and stuck-sensor shapes, none of
  # which a range check can see — so fall back to the cruise with the most
  # full-resolution scans, i.e. the most to look at.
  scope_reason <- "most out-of-range values"
  if (!length(worst_cruise) || is.na(worst_cruise[1])) {
    worst_cruise <- dbGetQuery(fcon, "
      SELECT cruise_key, COUNT(*) AS n FROM obs_ctd_full
      GROUP BY 1 ORDER BY n DESC LIMIT 1")$cruise_key
    scope_reason <- "no cruise has out-of-range values; most full-resolution scans"
  }
  say(glue("cruise scoped to: {worst_cruise} ({scope_reason})"))

  qc_res_cr <- qc_run_all(
    fcon, rules_cruise, limit = 500L, present_types = f_types,
    scope_values = list(cruise_key = worst_cruise))
  qc_sum_cr <- qc_summarize(qc_res_cr, rules_cruise)
} else {
  worst_cruise <- NA_character_
  qc_sum_cr    <- NULL
  say("obs_ctd_full not present — the profile rules are SKIPPED, not passed")
}
cruise scoped to: 2022-04-3322 (no cruise has out-of-range values; most full-resolution scans)
Code
if (!is.null(qc_sum_cr)) {
  qc_sum_cr |>
    transmute(rule_key, status, findings = n, severity, type = rule_type,
              description, `sec` = elapsed_s, note) |>
    datatable(
      caption  = glue("Profile rules over obs_ctd_full for cruise {worst_cruise}"),
      rownames = FALSE,
      options  = list(pageLength = 10, scrollX = TRUE, dom = "t")) |>
    formatStyle(
      "status",
      color = styleEqual(c("pass", "flag", "FAIL", "ERROR", "skip"),
                         c("#1a7f37", "#b06000", "#d03b3b", "#d03b3b", "#6e7781")))
} else {
  say("no cruise-scoped rule results this render")
}

34.7 Questions for Data Providers

Open questions on this dataset, tracked in metadata/calcofi/ctd-cast/questions.csv and surfaced here so they travel with the workflow rather than living in someone’s inbox. Everything above that could not be resolved from the data alone is written down here, with the measured evidence that raised it, so that asking is a matter of sending a link rather than reconstructing an investigation.

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 CalCOFI CTD data providers (ranked)")
Code
close_duckdb(fcon)

35 Cleanup

Code
close_duckdb(con)

# remove checkpoint after successful completion (fresh next run)
if (file_exists(db_checkpoint)) {
  file_delete(db_checkpoint)
  say(glue("Removed checkpoint: {db_checkpoint}"))
}
Removed checkpoint: /Users/bbest/Github/CalCOFI/workflows/data/wrangling/calcofi_ctd-cast_checkpoint.duckdb
Code
# the fingerprint is recorded in [record_fingerprint], immediately after the
# outputs are complete and before the GCS upload — see the reasoning there

36 Appendix: Interactive Gantt Chart

NoteThis appendix cannot fail the ingest

error: true is deliberate. Everything above this point — the parquet outputs and the GCS sync — is the ingest’s actual product, and it is all complete by the time this renders. On 2026-07-30 a stale path in this cosmetic chart aborted the whole target after a successful 15-minute run, so targets recorded a failure and release_database never started, over a chart. A decorative appendix should degrade to a visible error message, not discard finished work.

Code
# On a fast re-render (inputs unchanged) the heavy path never ran, so ctd_cast was
# never built in the wrangling DB. Gate the chart on its presence rather than
# letting a cosmetic appendix throw — the ingest's real product is already done.
con_pq <- get_duckdb_con(db_path)
load_duckdb_extension(con_pq, "spatial")
has_ctd_cast <- "ctd_cast" %in% DBI::dbListTables(con_pq)
if (!has_ctd_cast) {
  close_duckdb(con_pq)
  say(paste0(
    "ctd_cast not materialized (inputs unchanged, heavy path skipped) — no Gantt ",
    "chart this render. Re-run with CTD_FORCE_REBUILD=TRUE to rebuild it."))
}
Code
# Read ctd_cast from the WRANGLING DB, not from parquet.
#
# This chunk used to read '{dir_parquet}/ctd_cast.parquet'. That file stopped
# being written at the consolidated-core cut-over — Write Parquet now exports
# sample / obs / obs_ctd_full / measurement_type, with ctd_cast, ctd_thin,
# ctd_measurement and ctd_summary superseded. The breakage went unnoticed only
# because this target was excluded from `_targets.R` for the whole period since,
# so re-enabling it surfaced a latent failure: the ingest completed all of its
# real work (parquet written, GCS synced) and then died in a cosmetic appendix.
#
# sample.parquet is the natural modern substitute but has no `data_stage`, and
# the final-vs-preliminary split is the point of the chart. ctd_cast is still a
# real table in the wrangling DB at render time, so read it there — the connection
# is opened by the guard chunk above.
cruise_spans <- tbl(con_pq, "ctd_cast") |>
  group_by(cruise_key, data_stage) |>
  summarize(
    beg_date = min(datetime_start_utc, na.rm = TRUE) |> as.Date(),
    end_date = max(datetime_start_utc, na.rm = TRUE) |> as.Date(),
    .groups = "drop"
  ) |>
  collect() |>
  filter(!is.na(beg_date), !is.na(end_date))

# split multi-year cruises into separate rows
cruise_spans <- cruise_spans |>
  rowwise() |>
  mutate(
    years_spanned = list(year(beg_date):year(end_date))
  ) |>
  unnest(years_spanned) |>
  mutate(
    year = years_spanned,
    adj_beg_date = if_else(
      years_spanned == year(beg_date),
      beg_date,
      as.Date(paste0(years_spanned, "-01-01"))
    ),
    adj_end_date = if_else(
      years_spanned == year(end_date),
      end_date,
      as.Date(paste0(years_spanned, "-12-31"))
    ),
    begin_jday = yday(adj_beg_date),
    end_jday = yday(adj_end_date),
    stage_label = data_stage,
    hover_text = glue(
      "Cruise: {cruise_key}<br>",
      "Stage: {data_stage}<br>",
      "Begin: {format(beg_date, '%Y-%m-%d')}<br>",
      "End: {format(end_date, '%Y-%m-%d')}<br>",
      "Duration: {as.numeric(difftime(end_date, beg_date, units = 'days'))} days"
    )
  ) |>
  ungroup() |>
  arrange(adj_beg_date)

# green = done, warm = in progress; the two preliminary tiers share a hue so the
# eye reads "not final yet" first and the tier second
colors <- c(
  final                  = "#23d355ff",
  preliminary_with_bottle = "#A23B72",
  preliminary_without_bottle        = "#E8862E")

p <- plot_ly()

for (i in 1:nrow(cruise_spans)) {
  row <- cruise_spans[i, ]
  p <- p |>
    add_trace(
      type = "scatter",
      mode = "lines",
      x = c(row$begin_jday, row$end_jday),
      y = c(row$year, row$year),
      line = list(
        color = colors[row$stage_label],
        width = 8
      ),
      text = row$hover_text,
      hoverinfo = "text",
      showlegend = FALSE,
      legendgroup = row$stage_label
    )
}

# one legend entry per stage actually present, in processing order — hand-written
# traces drifted from `colors` when the third tier was added
for (stg in CTD_DATA_STAGES) {
  if (!stg %in% cruise_spans$stage_label) next
  p <- p |>
    add_trace(
      type = "scatter",
      mode = "lines",
      x = c(NA, NA),
      y = c(NA, NA),
      line = list(color = colors[[stg]], width = 8),
      name = CTD_STAGE_LABELS[[stg]],
      legendgroup = stg,
      showlegend = TRUE
    )
}

p <- p |>
  layout(
    title = "CalCOFI CTD Cruise Timeline",
    xaxis = list(
      title = "Day of Year",
      range = c(1, 365),
      dtick = 30,
      tickangle = 0
    ),
    yaxis = list(
      title = "Year",
      autorange = "reversed",
      dtick = 1
    ),
    hovermode = "closest",
    plot_bgcolor = "#f8f9fa",
    paper_bgcolor = "white",
    legend = list(
      x = 1.02,
      y = 1,
      xanchor = "left",
      yanchor = "top"
    )
  )

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

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

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

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

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