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
Web Scraping: Scrapes all CTD .zip download links from calcofi.org
Smart Filtering:
Downloads all .zip files for archival completeness
Only unzips final and preliminary files
Skips raw/cast/test files
Priority-based Selection:
For each cruise, selects final if available, otherwise preliminary
Excludes raw/test/prodo cast files
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
# 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 metadatad_meas_type <-read_csv(here("metadata/measurement_type.csv"),show_col_types = F)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)
3 Check for Resumable State
Code
# detect if parquet outputs are already complete (e.g. prior run failed# only during GCS upload). If overwrite=TRUE, always rebuild.parquet_complete <-FALSEmanifest_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) { p <-file.path(dir_parquet, paste0(tbl, ".parquet")) d <-file.path(dir_parquet, tbl)file_exists(p) ||dir_exists(d) }, logical(1)))if (parquet_ok &&!overwrite) { parquet_complete <-TRUEmessage(glue("Parquet output already complete ({length(mf$tables)} tables, ","{format(mf$total_rows, big.mark = ',')} rows) — ","skipping computation, resuming at upload")) }}# if parquet not complete, check for checkpoint (ctd_raw pre-computed)has_ctd_raw <-FALSEif (!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")$nmessage(glue("Checkpoint: ctd_raw already loaded ","({format(n_raw, big.mark = ',')} rows) — ","skipping read+bind+filter")) }}# set eval for read+bind+filter chunksskip_read_bind <- parquet_complete || has_ctd_rawknitr::opts_chunk$set(eval =!skip_read_bind)
4 Scrape CTD Download Links
Code
# read web page, extract all .zip download links; cache to CSVcache_csv <-file.path(dir_meta, "ctd_zip_urls.csv")d_zips <-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 =str_extract(url, "/(\\d{4})/", group =1) |>as.integer(),month =str_extract(url, "-\\d{2}(\\d{2})", group =1) |>as.integer(),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" ) )write_csv(d, cache_csv)message(glue("Scraped {nrow(d)} zip URLs from {url}")) d },error =function(e) {if (file_exists(cache_csv)) {message(glue("Website unavailable, using cached URLs from {cache_csv}"))read_csv(cache_csv, show_col_types =FALSE) } else {stop(e) } })stopifnot(!any(d_zips$zip_type =="unknown"))d_zips |>mutate(file_zip =glue("<a href={url}>{file_zip}</a>") ) |>select(-url) |>dt(caption ="All zip files to download",fname ="ctd_zip_files",escape = F )
5 Prime Downloads from GCS Source (optional)
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. If that source exposes zips, copy them into dir_dl so the next chunk simply unzips them rather than re-scraping calcofi.org. This is a no-op (falls back to calcofi.org) until the GCS source is populated; set CTD_ZIP_SOURCE="" to disable, or override it to point elsewhere.
Code
# authoritative zip source (gdrive→gcs); empty string disables primingctd_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 unavailableif (src ==""||Sys.which("rclone") =="")return(invisible(0L))# only act if the source actually exposes zips (else fall back to scraping) n_src <-tryCatch(length(system2("rclone", c("lsf", src, "--include", "*.zip", "--max-depth", "1"),stdout =TRUE, stderr =FALSE)),error =function(e) 0L)if (n_src ==0) {message(glue("No zips at {src} — skipping GCS prime (will use calcofi.org)"))return(invisible(0L)) }message(glue("Priming {n_src} zip(s) from {src} → {dest_dir}"))system2("rclone", c("copy", src, dest_dir,"--include", "*.zip", "--max-depth", "1","--transfers", "8", "--checkers", "16"))invisible(n_src)}prime_zips_from_gcs(ctd_zip_source, dir_dl)