Skip to content

Commit 2d3236b

Browse files
bbestclaude
andcommitted
feat: data_stage on core sample; question registry reader; measurement depth range (3.4.0)
Three additions, all driven by the CTD QA/QC loose ends (workflows plan 2026-08-02, Phase 1). data_stage on core `sample`, optional and trailing -------------------------------------------------- The source CTD files mark preliminary cruises "for non-publication use" and warn that oxygen, nitrate and chlorophyll may move significantly after post-cruise calibration. The ingest always knew which cruises were which; the released `sample` had nowhere to put it, so the caveat stopped at the notebook (question calcofi_ctd-cast_14). `append_sample()` now takes 15 OR 16 columns; the 16th is `data_stage` and a 15-column arm gets NULL. `select_sql` binds positionally and 16 ingests call it, so inserting the column into the contract would have broken all 16 at once. Trailing and optional means only the dataset with a meaningful stage changes. A 14- or 17-column arm now fails with a named error rather than DuckDB's "table function has N columns but M names were given". `.ensure_sample_schema()` also ALTERs an existing table: each ingest's wrangling DB survives across runs, so CREATE TABLE IF NOT EXISTS alone would leave a pre-3.4.0 `sample` a column short. Release assembly needed no change -- `assemble_core_table()` unions the shards BY NAME. The provider-question registry gets one reader and one vocabulary ----------------------------------------------------------------- `questions.csv` (136 questions, 17 files) was read by each ingest notebook with its own `read_csv()` + `arrange(factor(priority, ...))` + `select()`. The level vectors disagreed, so a status nobody listed sorted silently to the bottom and was never seen again; ingest_calcofi_mets.qmd ranked by a vector containing "blocker" and "asked", neither of which is a status. - read_questions() validated read; unknown status/priority, duplicate or malformed label, or a missing column is a named error - questions_datatable() the one render; all-empty columns dropped - question_statuses() / question_priorities() the vocabulary itself `proposed` is the new state and the point of it: we already have an answer and want it confirmed, so the provider approves a solution rather than being handed a problem. measurement_type: a depth range and a derivation ------------------------------------------------- merge_metadata_json() carries three more registry columns into the release sidecar. valid_depth_min_m/max_m is the depth over which a type is DEFINED -- est_chlorophyll_a_* is computed for 0-200 m alone, so a null at 300 m is by construction, not missing data. `derivation` is free text on how a derived type was produced; the CTD files publish every property three times (SBE-processed, _CruiseCorr, _StaCorr) and the suffix was the only thing distinguishing them. Each is OMITTED when the registry cell is empty: an emitted "valid_max": null reads as "no upper bound", an assertion the registry never made. 528 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjZtJgXv9Q2riNTCD4C8jJ
1 parent 657e4b6 commit 2d3236b

14 files changed

Lines changed: 647 additions & 23 deletions

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: calcofi4db
22
Title: CalCOFI Database Tools
3-
Version: 3.3.0
3+
Version: 3.4.0
44
URL: https://calcofi.io/calcofi4db
55
BugReports: https://github.com/calcofi/calcofi4db/issues
66
Authors@R:

NAMESPACE

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ export(qc_stage_reference)
146146
export(qc_summarize)
147147
export(qc_upload_con)
148148
export(query_at_time)
149+
export(question_priorities)
150+
export(question_statuses)
151+
export(questions_datatable)
149152
export(read_calcofi_meta)
150153
export(read_csv_files)
151154
export(read_csv_metadata)
@@ -156,6 +159,7 @@ export(read_ingest_yaml)
156159
export(read_input_fingerprint)
157160
export(read_measurement_type)
158161
export(read_parquet_table)
162+
export(read_questions)
159163
export(read_relationships_json)
160164
export(read_sbe_asc)
161165
export(read_sbe_btl)
@@ -279,6 +283,7 @@ importFrom(purrr,map_dfr)
279283
importFrom(purrr,map_int)
280284
importFrom(purrr,map_lgl)
281285
importFrom(purrr,pluck)
286+
importFrom(readr,col_character)
282287
importFrom(readr,cols)
283288
importFrom(readr,read_csv)
284289
importFrom(readr,write_csv)

NEWS.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,71 @@
1+
# calcofi4db 3.4.0
2+
3+
## `data_stage` on core `sample` — optional, trailing, opt-in
4+
5+
The source CTD files mark preliminary cruises **"for non-publication use"** and
6+
warn that oxygen, nitrate and chlorophyll may change significantly after
7+
post-cruise calibration. `ingest_calcofi_ctd-cast.qmd` has always known which
8+
cruises are which, and the released `sample` had nowhere to put it — so the
9+
caveat stopped at the notebook (question `calcofi_ctd-cast_14`).
10+
11+
- **`append_sample()` now accepts 15 **or** 16 columns.** The 16th, trailing, is
12+
`data_stage`; a 15-column arm gets `NULL`. `select_sql` is bound positionally
13+
and 16 ingests call it, so inserting the column into the contract would have
14+
broken all 16 at once — trailing and optional means only the dataset that has a
15+
meaningful stage changes, and the rest opt in later.
16+
- A 14- or 17-column arm now fails with a named error rather than DuckDB's
17+
"table function has N columns but M names were given".
18+
- `sample` gains `data_stage VARCHAR`. `.ensure_sample_schema()` also ALTERs an
19+
existing table, since each ingest's wrangling DB survives across runs and
20+
`CREATE TABLE IF NOT EXISTS` alone would leave a pre-3.4.0 `sample` a column
21+
short.
22+
23+
Release assembly needed no change: `assemble_core_table()` unions the shards
24+
`BY NAME`, so a shard written before this release simply reads `NULL`.
25+
26+
## The provider-question registry gets one reader and one vocabulary
27+
28+
`metadata/{provider}/{dataset}/questions.csv` — 136 questions across 17 files —
29+
was read by each of the 16 ingest notebooks with its own `read_csv()` +
30+
`arrange(factor(priority, …))` + `select(…)`. The level vectors disagreed, so a
31+
status nobody listed sorted silently to the bottom and was never seen again;
32+
`ingest_calcofi_mets.qmd` ranked by a vector containing `"blocker"` and
33+
`"asked"`, neither of which is a status. Four spellings of "done" and two of
34+
"normal" had accumulated.
35+
36+
- **`read_questions()`** — the one validated read. Strict (`na = ""`, everything
37+
character, so an id suffix of `01` is never retyped to `1`), checks the
38+
controlled vocabulary, and returns the questions ranked `blocker``low`.
39+
An unknown `status`/`priority`, a duplicate `label` or a missing column is an
40+
error naming the value, not a silent drop.
41+
- **`questions_datatable()`** — the standard render every notebook now calls.
42+
Columns empty for every question are dropped, so a dataset with no answers yet
43+
does not show two blank columns.
44+
- **`question_statuses()` / `question_priorities()`** — the vocabulary itself:
45+
`open | proposed | answered | wontfix` and `blocker | high | normal | low`.
46+
47+
**`proposed` is the new state and the point of the exercise**: we have already
48+
built or reasoned an answer and want it *confirmed*. `proposed_answer` carries
49+
it, so the provider approves a solution rather than being handed a problem.
50+
51+
## The measurement registry can now state a depth range and a derivation
52+
53+
`merge_metadata_json()` carries three more `measurement_type.csv` columns into
54+
the release sidecar's `measurement_types` block, alongside the existing
55+
`valid_min`/`valid_max`:
56+
57+
- **`valid_depth_min_m` / `valid_depth_max_m`** — the depth range over which the
58+
type is *defined*. `est_chlorophyll_a_*` is computed by applying the
59+
fluorometer regression to 0–200 m alone, so a null at 300 m is by construction,
60+
not missing data, and a completeness check had no way to know that.
61+
- **`derivation`** — free text on how a derived type was produced. The CTD files
62+
publish every property three times (SBE-processed, `_CruiseCorr`, `_StaCorr`)
63+
and the suffix was the only thing distinguishing them.
64+
65+
Every one of these is **omitted** from the sidecar when the registry cell is
66+
empty. An emitted `"valid_max": null` reads as "no upper bound" — an assertion
67+
the registry never made.
68+
169
# calcofi4db 3.3.0
270

371
## The QA/QC rule engine moves into the package

R/model.R

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,14 @@ ns_key <- function(dataset_key, sample_type, id_sql) {
138138
depth_min_m DOUBLE,
139139
depth_max_m DOUBLE,
140140
tow_type VARCHAR,
141+
data_stage VARCHAR,
141142
geom GEOMETRY)"))
143+
# `data_stage` was added in 3.4.0. The wrangling DB survives across runs (each
144+
# ingest restores from a checkpoint), so CREATE TABLE IF NOT EXISTS alone would
145+
# leave a pre-3.4.0 `sample` a column short and the INSERT below would fail on
146+
# a stale DB rather than on anything the caller did.
147+
DBI::dbExecute(con, glue::glue(
148+
"ALTER TABLE {tbl} ADD COLUMN IF NOT EXISTS data_stage VARCHAR"))
142149
invisible(tbl)
143150
}
144151

@@ -245,14 +252,23 @@ append_sample_measurement <- function(con, select_sql, tbl = "sample_measurement
245252

246253
#' Append event rows into the core `sample` dimension
247254
#'
248-
#' `select_sql` must yield `sample_key`, `sample_type`, `parent_sample_key`,
249-
#' `root_sample_key`, `dataset_key`, `grid_key`, `cruise_key`, `latitude`,
250-
#' `longitude`, `datetime`, `depth_min_m`, `depth_max_m`, `tow_type` by name;
251-
#' `geom` is minted here as `ST_Point(longitude, latitude)`. `tow_type` is the net
252-
#' gear code (ichthyo tow/net grains: C1/CB/CV/PV oblique/vertical, MT manta), NULL
253-
#' for gears/datasets without one. Call it once per event level — a multi-level
255+
#' `select_sql` is bound **positionally**, so it must yield either the 15 columns
256+
#' of the base contract — `sample_key`, `sample_type`, `parent_sample_key`,
257+
#' `root_sample_key`, `dataset_key`, `grid_key`, `site_key`, `cruise_key`,
258+
#' `order_occ`, `latitude`, `longitude`, `datetime`, `depth_min_m`, `depth_max_m`,
259+
#' `tow_type` — or those 15 plus a trailing 16th, `data_stage`. `geom` is minted
260+
#' here as `ST_Point(longitude, latitude)`. `tow_type` is the net gear code
261+
#' (ichthyo tow/net grains: C1/CB/CV/PV oblique/vertical, MT manta), NULL for
262+
#' gears/datasets without one. Call it once per event level — a multi-level
254263
#' dataset (ichthyo `site`->`tow`->`net`, bottle `cast`->`bottle`) appends one arm
255264
#' per level, and [sample_arm_self()] writes the single-level case for you.
265+
#'
266+
#' `data_stage` is **optional and trailing** on purpose: it records the source's
267+
#' own processing state for the event (`final` vs `preliminary` for CTD casts, per
268+
#' question `calcofi_ctd-cast_14`), which most datasets do not distinguish. Making
269+
#' it positional column 16 rather than inserting it into the contract lets a
270+
#' dataset opt in when it has a meaningful stage without touching the other arms —
271+
#' a 15-column arm gets `NULL` and keeps working unchanged.
256272
#' @inheritParams append_obs
257273
#' @param sample_tbl target table (default `"sample"`)
258274
#' @return (invisibly) the total row count of `sample_tbl` after the append
@@ -261,19 +277,34 @@ append_sample_measurement <- function(con, select_sql, tbl = "sample_measurement
261277
append_sample <- function(con, select_sql, sample_tbl = "sample") {
262278
.load_spatial(con)
263279
.ensure_sample_schema(con, sample_tbl)
280+
281+
src_cols <- c(
282+
"sample_key", "sample_type", "parent_sample_key", "root_sample_key",
283+
"dataset_key", "grid_key", "site_key", "cruise_key", "order_occ",
284+
"latitude", "longitude", "datetime", "depth_min_m", "depth_max_m", "tow_type")
285+
# DESCRIBE, not a LIMIT 0 scan: the arity has to be known before the positional
286+
# alias list is written, and a 15-vs-16 mismatch must be a named error rather
287+
# than DuckDB's "table function has N columns but M names were given".
288+
n_col <- nrow(DBI::dbGetQuery(con, glue::glue("DESCRIBE ({select_sql})")))
289+
if (!n_col %in% c(15L, 16L))
290+
stop("append_sample(): `select_sql` must yield 15 columns (the base contract) ",
291+
"or 16 (with `data_stage` trailing); got ", n_col, ".", call. = FALSE)
292+
has_stage <- n_col == 16L
293+
if (has_stage) src_cols <- c(src_cols, "data_stage")
294+
stage_sel <- if (has_stage) "data_stage" else "NULL::VARCHAR AS data_stage"
295+
src_alias <- paste(src_cols, collapse = ", ")
296+
264297
DBI::dbExecute(con, glue::glue(
265298
"INSERT INTO {sample_tbl}
266299
(sample_key, sample_type, parent_sample_key, root_sample_key,
267300
dataset_key, grid_key, site_key, cruise_key, order_occ, latitude, longitude, datetime,
268-
depth_min_m, depth_max_m, tow_type, geom)
301+
depth_min_m, depth_max_m, tow_type, data_stage, geom)
269302
SELECT sample_key, sample_type, parent_sample_key, root_sample_key,
270303
dataset_key, grid_key, site_key, cruise_key, order_occ, latitude, longitude, datetime,
271-
depth_min_m, depth_max_m, tow_type,
304+
depth_min_m, depth_max_m, tow_type, {stage_sel},
272305
CASE WHEN latitude IS NULL OR longitude IS NULL THEN NULL
273306
ELSE ST_Point(longitude, latitude) END AS geom
274-
FROM ( {select_sql} ) AS src(sample_key, sample_type, parent_sample_key, root_sample_key,
275-
dataset_key, grid_key, site_key, cruise_key, order_occ, latitude, longitude, datetime,
276-
depth_min_m, depth_max_m, tow_type)"))
307+
FROM ( {select_sql} ) AS src({src_alias})"))
277308
invisible(DBI::dbGetQuery(
278309
con, glue::glue("SELECT COUNT(*) AS n FROM {sample_tbl}"))$n)
279310
}

R/questions.R

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# the provider-question registry -----------------------------------------------
2+
#
3+
# Every dataset carries `metadata/{provider}/{dataset}/questions.csv`: what we
4+
# could not settle from the data, with the evidence that raised it. 17 files and
5+
# 136 questions accumulated four spellings of "done" (`open` / `answered` /
6+
# `resolved` / `wontfix`) and two of "normal" (`normal` / `medium`), because each
7+
# ingest notebook read the CSV with a bare `read_csv()` and sorted by its own
8+
# hand-written factor level vector — a status nobody's vector listed simply sorted
9+
# to the bottom and was never seen again.
10+
#
11+
# So the vocabulary lives here, in one validated read that every notebook calls.
12+
13+
#' The controlled vocabulary of the question registry
14+
#'
15+
#' `status`:
16+
#' \describe{
17+
#' \item{`open`}{asked, no answer and no proposal}
18+
#' \item{`proposed`}{**we have an answer to approve, not a problem to hand over**
19+
#' — `proposed_answer` holds what we did or suggest, and the provider is
20+
#' confirming it}
21+
#' \item{`answered`}{settled; `answer` holds the resolution}
22+
#' \item{`wontfix`}{closed without an answer, deliberately}
23+
#' }
24+
#'
25+
#' `priority`: `blocker` (the ingest cannot be released as-is), `high`, `normal`,
26+
#' `low`.
27+
#'
28+
#' @return Character vector of the allowed values.
29+
#' @export
30+
#' @concept registry
31+
#' @examples
32+
#' question_statuses()
33+
#' question_priorities()
34+
question_statuses <- function() c("open", "proposed", "answered", "wontfix")
35+
36+
#' @rdname question_statuses
37+
#' @export
38+
question_priorities <- function() c("blocker", "high", "normal", "low")
39+
40+
QUESTION_COLS <- c(
41+
"label", "id", "question", "context", "status", "priority",
42+
"proposed_answer", "answer", "asked_date", "answered_date", "who",
43+
"related_table", "related_field")
44+
45+
#' Read a dataset's `questions.csv`, validated and ranked
46+
#'
47+
#' The single reader for the provider-question registry. Reads strictly
48+
#' (`na = ""`, everything character, so a date or an id like `01` is never
49+
#' silently retyped), checks the controlled vocabulary, and returns the questions
50+
#' ranked `blocker` → `low` then by `label`.
51+
#'
52+
#' Two identifiers, deliberately:
53+
#' * **`id`** — `{provider}_{dataset}_{nn}`, globally unique and durable. This is
54+
#' what a cross-dataset reference or an issue tracker cites.
55+
#' * **`label`** — the short form (`Q15`), unique *within* the dataset. This is
56+
#' what prose in a notebook says, and what the rendered table shows first, so
57+
#' "see Q15" resolves for a reader.
58+
#'
59+
#' @param path path to a `questions.csv`
60+
#' @param validate error on an unknown `status`/`priority`, a duplicate `label`,
61+
#' or a `label` that disagrees with `id` (default TRUE)
62+
#'
63+
#' @return A [tibble][tibble::tibble], all columns character, ranked.
64+
#' @export
65+
#' @concept registry
66+
#' @importFrom readr read_csv cols col_character
67+
#' @examples
68+
#' \dontrun{
69+
#' read_questions("metadata/calcofi/ctd-cast/questions.csv")
70+
#' }
71+
read_questions <- function(path, validate = TRUE) {
72+
stopifnot("questions.csv not found" = file.exists(path))
73+
# na = "" and all-character: an `asked_date` of "" must stay empty rather than
74+
# becoming the string "NA" on the next write (see R/registry.R)
75+
d <- readr::read_csv(path, na = "", show_col_types = FALSE,
76+
col_types = readr::cols(.default = readr::col_character()))
77+
78+
miss <- setdiff(QUESTION_COLS, names(d))
79+
if (length(miss))
80+
stop("questions registry ", path, " is missing column(s): ",
81+
paste(miss, collapse = ", "),
82+
"\n Expected: ", paste(QUESTION_COLS, collapse = ", "), call. = FALSE)
83+
84+
if (isTRUE(validate)) {
85+
check_registry_na_strings(d, path)
86+
87+
bad <- setdiff(stats::na.omit(unique(d$status)), question_statuses())
88+
if (length(bad))
89+
stop("unknown question status in ", path, ": ", paste(bad, collapse = ", "),
90+
"\n Allowed: ", paste(question_statuses(), collapse = " | "),
91+
call. = FALSE)
92+
93+
bad <- setdiff(stats::na.omit(unique(d$priority)), question_priorities())
94+
if (length(bad))
95+
stop("unknown question priority in ", path, ": ", paste(bad, collapse = ", "),
96+
"\n Allowed: ", paste(question_priorities(), collapse = " | "),
97+
call. = FALSE)
98+
99+
dup <- unique(d$label[duplicated(d$label)])
100+
if (length(dup))
101+
stop("duplicate question label(s) in ", path, ": ",
102+
paste(dup, collapse = ", "),
103+
"\n `label` must be unique within a dataset — it is what prose cites.",
104+
call. = FALSE)
105+
106+
# `label` is AUTHORED, not derived from `id`. For 16 of the 17 registries it
107+
# is mechanically `Q` + the id's numeric suffix, but `calcofi/hydro-master`
108+
# carries two id namespaces (`hydro_master_*` and `recon_*`) that would
109+
# collide on that rule, and its ids are cited by name in the protocol and the
110+
# CTD ingest — so the id stays and the label disambiguates (`Q01` / `QR01`).
111+
off <- which(is.na(d$label) | !grepl("^Q[0-9A-Za-z]+$", d$label))
112+
if (length(off))
113+
stop("malformed question label(s) in ", path, ": ",
114+
paste(sprintf("%s (id %s)", d$label[off], d$id[off]), collapse = "; "),
115+
"\n Expected the short display form, e.g. Q15.", call. = FALSE)
116+
}
117+
118+
d[order(match(d$priority, question_priorities()), d$label), , drop = FALSE]
119+
}
120+
121+
#' Render a question registry as the standard notebook table
122+
#'
123+
#' The `## Questions for Data Providers` section every ingest notebook ends with.
124+
#' One call so the 16 notebooks cannot show different columns in different orders
125+
#' — which they did, each with its own hand-written priority factor.
126+
#'
127+
#' Columns that are empty for every question are dropped, so a dataset with no
128+
#' answers yet does not render two blank columns.
129+
#'
130+
#' @param x a path to a `questions.csv`, or a data.frame from [read_questions()]
131+
#' @param caption table caption
132+
#' @param page_length rows per page
133+
#'
134+
#' @return A [DT::datatable()] htmlwidget.
135+
#' @export
136+
#' @concept registry
137+
#' @importFrom DT datatable
138+
#' @examples
139+
#' \dontrun{
140+
#' questions_datatable(here::here(cc$questions_file))
141+
#' }
142+
questions_datatable <- function(x, caption = "Questions for data providers (ranked)",
143+
page_length = 25) {
144+
d <- if (is.character(x)) read_questions(x) else x
145+
keep <- c("label", "priority", "status", "question", "context",
146+
"proposed_answer", "answer", "related_table", "related_field")
147+
keep <- intersect(keep, names(d))
148+
d <- d[, keep, drop = FALSE]
149+
# an all-empty column is noise, not information
150+
d <- d[, vapply(d, function(v) any(!is.na(v) & nzchar(v)), logical(1)), drop = FALSE]
151+
152+
DT::datatable(
153+
d, caption = caption, rownames = FALSE,
154+
options = list(pageLength = page_length, scrollX = TRUE, dom = "tip",
155+
columnDefs = list(list(width = "60px", targets = 0))))
156+
}

0 commit comments

Comments
 (0)