CalCOFI CTD QA/QC Protocol

What every check does, where its threshold came from, and what it cannot see

Published

2026-08-14

What this document is

The reference for CalCOFI CTD quality control: every check that runs, stated plainly enough that a data manager can agree or disagree with it without reading SQL, and specific enough that a reviewer looking at a flagged cast knows what the flag means.

It is generated from the rules themselves. The per-rule sections below are assembled at render time from metadata/qc_rules/rules.csv for the index, sql/*.sql for the check and for the reasoning its author recorded in the file’s header comment. Nothing here is a separate description that could drift from the code: change a threshold and this page changes with it, and a rule that runs without a rationale fails this document’s build.

Code
devtools::load_all(here::here("../calcofi4db"))
librarian::shelf(
  calcofi4db, dplyr, DT, glue, here, knitr, purrr, readr, stringr,
  quiet = T)

source(here("libs/build_qc_protocol.R"))

dir_rules <- here("metadata/qc_rules")
rules_all <- qc_read_rules(dir_rules, active_only = FALSE)
rules_act <- filter(rules_all, active)

say <- function(...) cat(..., "\n", sep = "")
say(glue("rules: {nrow(rules_all)} registered, {nrow(rules_act)} active, ",
         "{nrow(rules_all) - nrow(rules_act)} parked"))
rules: 22 registered, 19 active, 3 parked
Code
say(glue("SQL files: {length(unique(na.omit(rules_all$sql_file)))}"))
SQL files: 15
Code
# This document renders to HTML and to **docx** — the latter so the CTD team can
# suggest edits in track-changes. A DT::datatable() is an htmlwidget and becomes
# nothing at all in Word, so any table a reviewer must actually see goes through
# this switch rather than straight to datatable(). Sorting/paging is a luxury;
# a blank space where the quality-code vocabulary should be is a defect.
tbl <- function(d, caption, page_length = 12, ...) {
  if (knitr::is_html_output())
    datatable(d, caption = caption, rownames = FALSE,
              options = list(pageLength = page_length, scrollX = TRUE, dom = "tip"),
              ...)
  else kable(d, caption = caption)
}

The pipeline these rules watch

Quality control here is not a stage bolted on at the end — it reads the same tables consumers read, at the point they are published. So the shape of the pipeline determines what a rule can see, and two steps in particular decide it: thinning, which is why a rule targeting obs is blind to most scans, and the cast-direction choice, which is why half of every physical cast carries no published observation at all.

Code
%% No `subgraph` on purpose: mermaid's default cluster fill is dark and renders
%% its own title illegibly against itself. Grouping is carried by classDef colour
%% and stated in the caption instead.
flowchart TD
  Z["<b>CTDBTL CSVs</b> — calcofi.org, one zip per cruise<br/>82 cols · <b>1 m bin averages, not raw scans</b><br/>one file per cast per direction"]
  D["<i>CTD-CSV-Format.pdf</i> · <i>PreliminaryDataReadme.txt</i><br/>ships in every zip"]
  M["<i>span</i> · <i>DBcoeff</i> · <i>xmlcoeff</i><br/>per-cruise metadata, not yet read"]

  Z --> R["<b>ctd_raw</b><br/>read · standardize · rename<br/>−99 sentinel deleted, not ingested<br/>distance filter · de-duplicate"]
  R --> C["<b>ctd_cast</b> — cast metadata<br/>+ <i>data_stage</i> = final / preliminary"]
  R --> ME["<b>ctd_measurement</b> — long<br/>type · value · qual · ~216 M scans"]

  ME --> T["<b>ctd_thin</b> — adaptive thinning<br/>1. one direction per physical cast<br/>2. canonical types only<br/>3. ~10 m grid + RDP inflections<br/>4. + every bottle-trip depth"]

  C --> S["<b>sample</b><br/>one row per <b>physical</b> cast"]
  T --> O["<b>obs</b> — the headline<br/>~7 M rows · realm = env"]
  ME --> F["<b>obs_ctd_full</b> — supplemental<br/>~212 M scans, opt-in"]

  S --> REL["frozen release<br/>gs://calcofi-db/ducklake/releases/"]
  O --> REL
  F --> REL

  S -.-> RULES["<b>metadata/qc_rules/</b><br/>rules.csv + sql/*.sql<br/><i>one engine, one registry</i>"]
  O -.-> RULES
  F -.->|"one cruise at a time"| RULES
  D -.->|"thresholds · caveats"| RULES
  M -.->|"question Q15"| RULES

  RULES --> N["ingest notebook — <i>Findings</i>"]
  RULES --> A["<b>ctd-qaqc</b> app<br/>+ ad-hoc .btl upload"]

  classDef src  fill:#eef3fb,stroke:#4a6fa5,color:#12243a
  classDef core fill:#e7f6ec,stroke:#3f8f5a,color:#0f2a1a
  classDef mid  fill:#f6f7f9,stroke:#8b95a3,color:#1c222b
  classDef qc   fill:#fdf3e7,stroke:#b06000,color:#3a2400
  class Z,D,M src
  class R,C,ME,T mid
  class S,O,F,REL core
  class RULES,N,A qc
%% No `subgraph` on purpose: mermaid's default cluster fill is dark and renders
%% its own title illegibly against itself. Grouping is carried by classDef colour
%% and stated in the caption instead.
flowchart TD
  Z["<b>CTDBTL CSVs</b> — calcofi.org, one zip per cruise<br/>82 cols · <b>1 m bin averages, not raw scans</b><br/>one file per cast per direction"]
  D["<i>CTD-CSV-Format.pdf</i> · <i>PreliminaryDataReadme.txt</i><br/>ships in every zip"]
  M["<i>span</i> · <i>DBcoeff</i> · <i>xmlcoeff</i><br/>per-cruise metadata, not yet read"]

  Z --> R["<b>ctd_raw</b><br/>read · standardize · rename<br/>−99 sentinel deleted, not ingested<br/>distance filter · de-duplicate"]
  R --> C["<b>ctd_cast</b> — cast metadata<br/>+ <i>data_stage</i> = final / preliminary"]
  R --> ME["<b>ctd_measurement</b> — long<br/>type · value · qual · ~216 M scans"]

  ME --> T["<b>ctd_thin</b> — adaptive thinning<br/>1. one direction per physical cast<br/>2. canonical types only<br/>3. ~10 m grid + RDP inflections<br/>4. + every bottle-trip depth"]

  C --> S["<b>sample</b><br/>one row per <b>physical</b> cast"]
  T --> O["<b>obs</b> — the headline<br/>~7 M rows · realm = env"]
  ME --> F["<b>obs_ctd_full</b> — supplemental<br/>~212 M scans, opt-in"]

  S --> REL["frozen release<br/>gs://calcofi-db/ducklake/releases/"]
  O --> REL
  F --> REL

  S -.-> RULES["<b>metadata/qc_rules/</b><br/>rules.csv + sql/*.sql<br/><i>one engine, one registry</i>"]
  O -.-> RULES
  F -.->|"one cruise at a time"| RULES
  D -.->|"thresholds · caveats"| RULES
  M -.->|"question Q15"| RULES

  RULES --> N["ingest notebook — <i>Findings</i>"]
  RULES --> A["<b>ctd-qaqc</b> app<br/>+ ad-hoc .btl upload"]

  classDef src  fill:#eef3fb,stroke:#4a6fa5,color:#12243a
  classDef core fill:#e7f6ec,stroke:#3f8f5a,color:#0f2a1a
  classDef mid  fill:#f6f7f9,stroke:#8b95a3,color:#1c222b
  classDef qc   fill:#fdf3e7,stroke:#b06000,color:#3a2400
  class Z,D,M src
  class R,C,ME,T mid
  class S,O,F,REL core
  class RULES,N,A qc
Figure 1: From the source zips to the tables the rules read. Blue = source; grey = intermediate, never published; green = the published core; amber = the rule registry and the two places it runs. Dotted edges are what the rules read, not what the pipeline writes.

Three consequences of that shape run through everything below.

A rule on obs cannot see what thinning removed. obs is ctd_thin, not ctd_measurement. Spikes, loop edits and down-vs-up disagreement are properties of the dense scan set, so those rules target obs_ctd_full and are run one cruise at a time — a sample, never a sweep.

Half of every physical cast has no published obs. Step ① keeps one direction. sample is one row per physical cast, so a completeness rule that expects an observation per cast direction would be wrong by construction.

Nothing here is a filter. Every rule returns a report. No rule drops, rewrites or interpolates a value, and two blocker questions remain open with the providers — acting on them before they are answered would be guessing with released data.

How to read a rule

Each rule declares four things that decide how a result should be read.

field what it changes
severity error means the pipeline is broken and the finding is a defect in our processing. warning means the data looks wrong and a human must decide. No rule drops or rewrites a value.
target sample (one row per cast), obs (the thinned, published profile) or obs_ctd_full (the full-resolution scans, supplemental). A rule on obs cannot see what thinning removed.
scope all runs over the whole dataset. cruise rules read the 212M-row obs_ctd_full and are run one cruise at a time — they are a sample, never a sweep, and the app is where the remaining cruises get covered.
requires measurement types that must be present. If one is absent the rule reports skip, never pass.

That last row is the single most important convention here.

Every rule returns at least subject_key (what is flagged — the unit of review) and detail (one sentence naming the problem). A rule whose finding concerns a particular scan rather than a whole cast also returns depth_min_m and measurement_type. Those two are a contract rather than a habit: they are what lets a reviewer click a finding in ctd-qaqc and land on the right profile at the right depth, without the app needing to know anything about the rule that produced it. The two cast-level rules below (ctd_cast_deeper_than_seafloor, ctd_cast_position_vs_station) correctly return neither — they are about the cast, not about a reading.

Importantskip is not pass, and this is not hypothetical

A rule whose input is missing returns zero rows — which is indistinguishable from “the data is clean” unless something says otherwise. The three bottle-vs-sensor calibration rules did exactly that against release v2026.07.30, which predates the change making the bottle reference types canonical: they reported no findings while comparing nothing at all.

The engine therefore treats an unmet precondition as skip with a stated reason, and the same principle governs reference data — calcofi4db::qc_stage_reference() leaves a missing input as a missing table so its rules error visibly, rather than creating an empty one whose rules would quietly pass.

Results carry one of five statuses:

status meaning
pass ran, found nothing
flag ran, found something a human should look at (severity = warning)
FAIL ran, found something that should not be possible (severity = error)
skip did not run — a precondition was unmet
ERROR could not run (missing reference table, bad SQL)

Where these rules came from

Two sources, deliberately combined: the checks CalCOFI has been running for decades, and the checks that only make sense on CTD data.

Mining the CalCOFI hydrographic master

The program’s working database is an MS Access file carrying the bottle record from 1949, and — more valuably — 155 saved queries, a self-documenting field dictionary, an instrument/accuracy registry, a 280-row dated change log, and a fitted seasonal climatology. Three decades of institutional QA logic lived there and nowhere else.

It was extracted reproducibly on macOS/Linux (libs/extract_accdb.R, scripts/extract_accdb.sh) with two engines on purpose: mdbtools for bulk table export, and Jackcess for the saved-query SQL, which is authoritative. mdb-queries reconstructs the stored parse tree only partially — it silently drops JOIN clauses, GROUP BY, HAVING and aliases. The output looks plausible and is wrong: a LEFT JOIN find-unmatched query degrades into a cross join, which inverts the meaning of the QA check it encodes. Both engines are run and diffed so a regression in either surfaces instead of silently corrupting a ported rule.

Of the 155 queries, only one family is standing QA: the TQ - / TR - / TV - tests and the Anomalies family. The rest are corrections, materializations and derived products.

Code
read_csv(here("metadata/calcofi/hydro-master/query_triage.csv"),
         show_col_types = FALSE) |>
  count(category, sort = TRUE) |>
  mutate(`what it is` = recode(category,
    validate           = "QA checks — the family ported here",
    `derived-product`  = "computed products (MLD, nutricline, anomalies)",
    `correction-history` = "one-off UPDATE/DELETE repairs, dated and initialled",
    materialization    = "make-table steps feeding other queries",
    `export-or-info`   = "extracts for outside consumers",
    unclassified       = "not confidently categorized")) |>
  kable(caption = "The 155 Access saved queries, triaged")
The 155 Access saved queries, triaged
category n what it is
validate 52 QA checks — the family ported here
derived-product 35 computed products (MLD, nutricline, anomalies)
correction-history 31 one-off UPDATE/DELETE repairs, dated and initialled
materialization 16 make-table steps feeding other queries
unclassified 13 not confidently categorized
export-or-info 8 extracts for outside consumers

A faithful port is not always the right port. Two of the Access checks could not be carried over as written, and both are documented on the rules that replaced them: TQ - BottomDepth_Vs_AvgBottomDepth compares the ship’s echosounder to the station average, but CTD casts carry no reported bottom depth at all (bottom_depth exists in sample_measurement for 33,363 bottle casts and for 0 of 14,336 CTD casts) — so the ported form tests position plausibility against bathymetry instead, which is a different question. And TQ - StationNameChecker compares Rpt_Line > "76.6" as a string, so line 100 sorts below line 76.6 and has been silently excluded from that check for twenty years; whether to port the bug or the intent is question hydro_master_09.

What the source documentation actually says

Every CTD source zip ships its own documentation — CTD-CSV-Format.pdf, CTD Data Files.pdf, FilenamingAndAscHeaderNotes.txt, and a PreliminaryDataReadme.txt on preliminary cruises. It answers several questions this project had open, and it constrains how the released values may be read. Combed 2026-08-01; what follows is from those files, not inferred.

The processing chain, and the three versions of every sensor value

Raw frames are processed with SBE Data Processing (Seasoft) tuned for the CalCOFI 911/911+, then by SIO-CalCOFI’s own BtlVsCTD against the cast’s bottle samples. The published CSV therefore carries the same property three times, and the column suffix is the only thing that distinguishes them:

suffix what it is fitted from
(none) SBE-processed, no bottle correction
_CruiseCorr corrected with one regression for the whole cruise 4-second-averaged sensor data vs all the cruise’s bottles (n ≈ 1400, fliers omitted)
_StaCorr corrected with a regression fitted per cast 1 m bin-averaged sensor data vs that cast’s bottles (n ≈ 20, fliers omitted)

The providers state that station-corrected data are the best, particularly for estimated nitrate — which is the basis on which *_sta_corr types are the canonical ones in this pipeline. That choice was previously undocumented here; it follows the source’s own recommendation.

Salinity is corrected differently again: offsets are derived from bottle salinities below 350 m and applied to all depths, separately for the primary and secondary sensors.

Four caveats that change how a value may be used

WarningRead these before drawing a conclusion from a corrected value
  1. Upcast bottles correct the downcast too. The source says so explicitly, and volunteers the objection: “This practice may be debated due to internal waves and hysteresis. If you disagree with bottle-correcting downcast sensor data, use the SBE-processed data.” Any down-vs-up comparison — including the ctd_updown_disagreement rule below — is therefore comparing two series that share a correction derived from one of them.
  2. _StaCorr is missing by design, not by error. It requires a 500 m cast with roughly ten or more bottles; shallower stations need several. On winter and spring cruises, lines north of 76.7 may be occupied with 0–12 bottles and get cruise-corrected values only. A completeness check that treats a null *_sta_corr as a defect will be wrong for a whole class of casts.
  3. Estimated chlorophyll is only computed for 0–200 m. EstChl_* comes from applying the fluorometer regression to the 1 m bin-averaged voltage over that range alone.
  4. Preliminary data are explicitly non-publication. The readme is specific about which variables move: “Temperature and salinities may change very little but oxygen, nitrate and chlorophyll data may change significantly after post-cruise calibrations.” Final data are annotated FinalQC.

Grain

The published values are 1 m bin averages, not raw scans — so obs_ctd_full is full published resolution, and the spike and loop-edit rules operate on bin-averaged data. That is why the loop-edit threshold is a 1 m noise floor. (Cruise-correction regressions use a 4-second average instead; the two grains are not interchangeable.)

Documented but unused: three metadata families

Every cruise’s metadata/ folder carries files the pipeline does not read. They are listed here because each answers a question this protocol currently has open:

file what is in it what it would settle
YYMM_span_###-###.csv per cast, the min and max of every variable, recorded by the processor a per-cast range check grounded in the instrument rather than in our invented valid_min/valid_max (question calcofi_ctd-cast_02)
YYMM_DBcoeff_###-###.csv per cast: Salt1Offset, Salt1_SD, the oxygen regression polynomials and their residual SDs the uncertainty of each correction, which is exactly what the core’s declared-but-empty measurement_prec is for
YYMM_xmlcoeff_###-###.csv per cast, per channel: sensor serial number and calibration date per-cast instrument provenance, far finer than the decade-scale eras in measurement_method.csv

All three were surveyed across the 85 cruises in hand on 2026-08-02, and they are not equally ready to depend on — which is the substance of the recommendation now recorded on question calcofi_ctd-cast_15:

file files rows distinct headers verdict
DBcoeff 135 15,703 1 (43 columns, no variation at all) ingest first — stable, and it carries the residual SDs measurement_prec wants
span 136 16,254 70 (61–79 columns; the set follows each cruise’s sensor suite) ingest second, as a long (cast, variable, min, max) melt
xmlcoeff 136 16,394 40 (1–198 columns; three files have a single column) not yet — no rule needs it and the shape costs the most to make reliable

They would land through a libs/ builder called from the CTD ingest, not a new ingest_*.qmd: these are per-cruise sidecars of calcofi_ctd-cast, not a separate dataset, and a new notebook would mint a dataset_key and a release shard for something that is not one. The per-cast scalars belong in sample_measurement, the event-level grain that already holds cast conditions.

Smaller points

  • Time and position mean different things by direction. For a downcast they are the cast start; for an upcast they are the moment the first bottle tripped.

  • Voltage channels are relabelled. SeaSave writes V0V7; CalCOFI renames them to TRANSV, FLUORV, ISUSV, PARV, OX1V, PHV, ALTV, RINKOV so the sensor is identifiable. Unrelabelled originals are kept under original_asc-hdr-btl/, and each cast’s .hdr records the configuration.

  • Filename grammar: YY-YYMMSS_LLLLSSSS_###D, where line and station are ×10 (0767 = 76.7, 1000 = 100.0) and D/U is the direction.

  • The spec’s Comment column does not exist in the files, and the files have a column the spec does not document. Settled by reading every *CTDBTL*.csv shipped in the zips — 245 files across 85 cruises, 1998-02 to 2025-04 — against the single CTD-CSV-Format.pdf version that all 84 copies of the spec contain. The two lists agree positionally through field 74 (OxB). The files then carry OxBuM (bottle oxygen, µmol/kg) at 75, which the spec never mentions; everything after shifts by one, so the file’s 82nd column is SIL (the spec’s 81st) and the spec’s Comment has no counterpart at all.

    So this is not an off-by-one in the spec: it is one undocumented column added and one documented column absent, which happen to cancel to the same total of 82. Nothing is being dropped by this pipeline — OxBuM is ingested as oxygen_btl_umol_kg — but a reader who trusts the spec’s field numbering will mis-read every column from 75 on. Raised as calcofi_ctd-cast_18 so the spec can be corrected at source.

    One file departs from all of this: 20-1804SH_CTDFinalQC/Metadata/ 20-1804SH_CTDBTL_001-103UResid.csv has 84 columns, adding BO2-SCO21 and BO2-SCO22 — per-sensor bottle-minus-CTD oxygen residuals. It is a diagnostic left in a Metadata/ folder, not a data file, and the ingest’s data_stage selector requires Final|Prelim.*db[_-]csv in the path, so it is filtered out rather than pivoted. It is worth noticing for a different reason: it is the residual that measurement_prec wants (question calcofi_ctd-cast_15), already computed by the provider for one cruise.

The climatology, and why two properties are excluded

HarmCoeffBottle holds a fitted annual harmonic per station × standard depth × property — mean, amplitude, frequency, phase, and a residual standard deviation. That turns “is this value plausible” from a fixed global range into a local, seasonal question, which nothing in the pipeline could ask before.

The providers have not confirmed the fitting procedure (question hydro_master_12), so the reconstruction form was determined empirically: candidate forms were scored against the 200,640 bottle observations the coefficients were derived from.

form RMSE vs mean-only
Mean (null) 1.269
Mean + Ampl·sin(Freq·(doy − Phase)) 0.949 +25.2%
Mean + Ampl·cos(Freq·(doy − Phase)) 2.113 −66.4%
Mean + Ampl·cos(Freq·doy − Phase_deg) 1.800 −41.8%
Mean + Ampl·sin(2π(doy − Phase)/365) 1.474 −16.1%

Every cosine variant is worse than using the mean alone. Two independent checks agree with the winner: physically, the seasonal signal is strongest at the surface (42% RMSE reduction at 0 m) and decays monotonically to ~5% by 150–500 m, which is what seasonal heating does and what a wrong form would not show; statistically, residuals divided by StDev are near-standard-normal (mean −0.06, sd 1.14), confirming StDev is the residual scale a z-score needs.

Each property was then scored the same way, and only those that passed are used:

property RMSE gain sd(z) verdict
Temp 25.2% 1.14 use
SiO3 11.6% 1.12 use
NO3 10.3% 1.40 use
PO4 9.7% 1.26 use
O2 9.3% 1.29 use
Sig 2.4% 4.80 exclude
Sal 0.5% 6.73 exclude
NO2 5.0% 5.4e12 exclude
Chla 4.3% 1.2e11 exclude

Salinity and sigma-theta are excluded on evidence, not taste. A seasonal harmonic explains almost none of their variance here — Southern California salinity is advection-driven, not seasonally heated — and the tabulated StDev is 5–7× smaller than the actual residual, so it is measuring something other than residual spread. A z-score built on it would be wildly over-dispersed and would flag thousands of ordinary values. NO2 and Chla fail differently: a handful of station × depth cells carry a near-zero StDev, which makes z explode (one nitrite cell produced z = 3.5e12), so they are left out rather than patched.

Reference data the rules join against

None of this is part of the release. It is the QC engine’s input, staged onto a connection by calcofi4db::qc_stage_reference().

Code
tibble::tribble(
  ~table,                  ~source,                                        ~role,
  "measurement_type",      "metadata/measurement_type.csv",                "valid_min/valid_max per type — the registry, not a release, because it moves ahead of releases",
  "measurement_qual",      "metadata/measurement_qual.csv",                "the quality-code vocabulary, recovered from the Access field documentation",
  "climatology_harmonic",  "hydro-master/reference/climatology_harmonic.csv", "fitted seasonal expectation per station x depth x property",
  "station",               "hydro-master/reference/station.csv",           "the 75 current standard stations and their average bottom depth",
  "standard_depth",        "hydro-master/reference/standard_depth.csv",    "the standard depth grid",
  "sample_seafloor",       "GEBCO 2025 crop (apps/ctd-viz)",               "seafloor depth at each cast position — the only bottom-depth reference CTD casts have") |>
  kable(caption = "QC reference inputs, none of which ship in the release")
QC reference inputs, none of which ship in the release
table source role
measurement_type metadata/measurement_type.csv valid_min/valid_max per type — the registry, not a release, because it moves ahead of releases
measurement_qual metadata/measurement_qual.csv the quality-code vocabulary, recovered from the Access field documentation
climatology_harmonic hydro-master/reference/climatology_harmonic.csv fitted seasonal expectation per station x depth x property
station hydro-master/reference/station.csv the 75 current standard stations and their average bottom depth
standard_depth hydro-master/reference/standard_depth.csv the standard depth grid
sample_seafloor GEBCO 2025 crop (apps/ctd-viz) seafloor depth at each cast position — the only bottom-depth reference CTD casts have

The quality-code vocabulary

measurement_qual was a verbatim pass-through for years: carried through every table, never interpreted, with no vocabulary anywhere in the repo.

There are now two, and conflating them was a real error. The first was recovered from the hydrographic master — the bottle database. The CTD files use a different set, documented all along in the CTD-CSV-Format.pdf that ships inside every source zip:

code CTD meaning
0 or blank good — “if no data code is displayed then sensors were operating normally”
1 use the primary sensor of the dual pair
2 use the secondary sensor — the primary is failing or the pump is bio-fouled
8 data questionable
9 bad or missing sensor data

Two consequences. 1 and 2 are not quality grades at all — they are sensor-selection instructions, so ranking them somewhere between “good” and “suspect” is a category error. And 9 means bad or missing, which resolves what looked like a contradiction: a row flagged 9 that still carries a number is bad data, correctly flagged.

metadata/measurement_qual.csv therefore carries a code_set column (ctd / bottle) and the rule below filters to the CTD set — joining the whole file would let a bottle-only code mask an unrecognized CTD one. The bottle vocabulary remains incomplete, and those gaps are still blockers.

Code
d_qual <- read_csv(here("metadata/measurement_qual.csv"),
         col_types = cols(qual_code = col_character(), .default = col_guess())) |>
  select(set = code_set, code = qual_code, label, description,
         documented = is_documented, n_observed) |>
  arrange(set, code)

w <- tbl(d_qual, "metadata/measurement_qual.csv — the controlled vocabulary")
if (knitr::is_html_output())
  w <- formatStyle(w, "documented",
                   color = styleEqual(c(TRUE, FALSE), c("inherit", "#d03b3b")))
w

Questions remain open on the bottle side, two of them blockers: what the undocumented single-digit codes mean there (hydro_master_02 — the CTD documentation now supplies 0/1/2, but whether the bottle master uses them the same way is unconfirmed), and whether S_qual’s 253 distinct values — clustered at 256–271, which looks like bit 8 of a bitmask — indicate a bitmask or a corrupted column (hydro_master_02b). Every other quality column has 3–10 distinct values.

hydro_master_05 — whether a NULL flag means “good” or “never assessed” — is answered for CTD: the source states that no code displayed means the sensors were operating normally. It remains open for the bottle master.

Nothing in this protocol acts on a flag. Acting on one whose semantics are unconfirmed would be guessing with released data.

Code
d_rules <- rules_all |>
  transmute(rule_key, active, severity, type = rule_type, target,
            scope = coalesce(scope, "all"), description) |>
  arrange(desc(active), type, rule_key)

w <- tbl(d_rules, "The registry: metadata/qc_rules/rules.csv", page_length = 20)
if (knitr::is_html_output())
  w <- formatStyle(w, "active",
                   color = styleEqual(c(TRUE, FALSE), c("inherit", "#6e7781")))
w

The rules

Grouped by what kind of question each asks. Rules that share a query — the three bottle-vs-sensor calibration checks, the two spike checks — show the SQL once, under the first of them.

Code
md_active <- qc_protocol_markdown(
  arrange(rules_act, rule_type, rule_key), group_by = "rule_type")
cat(md_active)

Climatology rules

ctd_climatology_anomaly

checks value exceeds
type climatology
severity warning
runs against obs
scope whole dataset
requires temperature_ave — absent means skip, never pass
ported from the Access master’s Anomalies test family

Parameters

parameter value
depth_tol 2.5
z_threshold 5
min_stdev 1e-6

Climatological anomaly: how far a value sits from what this station, at this depth, at this time of year, normally reads.

This is the CalCOFI hydrographic master’s own expected-value engine, ported. HarmCoeffBottle holds a fitted annual harmonic per station x standard depth x property (mean, amplitude, frequency, phase, and the residual standard deviation), which turns “is this value plausible” from a fixed global range into a local, seasonal question. Nothing in the pipeline did this before.

RECONSTRUCTION FORM — determined empirically, not assumed. The providers have not confirmed the fitting procedure (question hydro_master_12), so candidate forms were scored against the 200,640 bottle observations the coefficients were derived from. Mean + Ampl*sin(Freq*(doy - Phase)) cut RMSE 25.2% below using the mean alone; every cosine variant did WORSE than the mean. See the header of libs/build_qc_reference.R for the full table and the two independent checks (seasonal signal decaying correctly with depth; residuals/StDev ~ N(0,1)).

DEPTH MATCHING is nearest-within-tolerance, not exact. ctd_thin retains the actual scan nearest each ~10 m level rather than resampling to round numbers, so observed depths look like 0.968, 10.03, 19.97 — an equality join would match almost nothing. The tolerance is well under the 10 m spacing, so the nearest standard depth is unambiguous; QUALIFY keeps exactly one match per observation.

Standard depths 75 m and 125 m are effectively unreachable: they are not multiples of 10, so the thinning grid rarely puts a scan within tolerance. That is a coverage limit of the grid, not a defect of this rule.

DEGENERATE CELLS ARE EXCLUDED. A few station x depth cells carry a StDev of effectively zero, which makes z explode — one nitrite cell produced z = 3.5e12 before this guard, which is an artifact of dividing by ~0, not a finding. A zero-variance climatology means too few observations in that cell, not a perfect fit.

Registry note. harmonic form determined empirically — see libs/build_qc_reference.R header

WITH o AS (
  SELECT
    o.sample_key, o.cruise_key, o.measurement_type,
    o.depth_min_m, o.measurement_value,
    s.site_key,
    CAST(strftime(o.datetime, '%j') AS DOUBLE) AS doy
  FROM obs o
  JOIN sample s USING (sample_key)
  WHERE o.dataset_key = 'calcofi_ctd-cast'
    AND o.measurement_value IS NOT NULL
    AND o.datetime IS NOT NULL
), m AS (
  SELECT
    o.*,
    c.depth_m AS std_depth_m,
    c.coef_mean, c.coef_ampl, c.coef_freq, c.coef_phase, c.coef_stdev
  FROM o
  JOIN climatology_harmonic c
    ON  o.site_key         = c.site_key
    AND o.measurement_type = c.measurement_type
    AND abs(o.depth_min_m - c.depth_m) <= {{depth_tol}}
  WHERE c.coef_stdev >= {{min_stdev}}
  QUALIFY ROW_NUMBER() OVER (
    PARTITION BY o.sample_key, o.measurement_type, o.depth_min_m
    ORDER BY abs(o.depth_min_m - c.depth_m)) = 1
), z AS (
  SELECT
    m.*,
    coef_mean + coef_ampl * sin(coef_freq * (doy - coef_phase)) AS expected,
    (measurement_value
       - (coef_mean + coef_ampl * sin(coef_freq * (doy - coef_phase))))
      / coef_stdev                                              AS z_score
  FROM m
)
SELECT
  sample_key                                                    AS subject_key,
  measurement_type || ' = ' || round(measurement_value, 3) ||
    ' at ' || round(depth_min_m, 1) || ' m is ' || round(z_score, 1) ||
    ' SD from the ' || site_key || ' climatology (expected ' ||
    round(expected, 3) || ')'                                   AS detail,
  cruise_key, site_key, measurement_type,
  depth_min_m, std_depth_m,
  round(measurement_value, 4) AS observed,
  round(expected, 4)          AS expected,
  round(coef_stdev, 4)        AS clim_stdev,
  round(z_score, 2)           AS z_score
FROM z
WHERE abs(z_score) > {{z_threshold}}

Completeness rules

ctd_cast_missing_position

checks cast with NULL longitude or latitude
type completeness
severity error
runs against sample
scope whole dataset

Casts with no usable position. The ingest backfills lon/lat by projecting line/station through +proj=calcofi and asserts none remain, so a hit here means that guard regressed or a new failure mode appeared upstream.

Registry note. ingest asserts none remain; a hit means that guard regressed

SELECT
  sample_key                                                   AS subject_key,
  'cast has NULL longitude or latitude'                        AS detail,
  cruise_key, site_key, datetime
FROM sample
WHERE dataset_key = 'calcofi_ctd-cast'
  AND sample_type = 'cast'
  AND (longitude IS NULL OR latitude IS NULL)

Crosscheck rules

ctd_bottle_sensor_offset_oxygen

checks bottle vs sensor oxygen offset at matched depth
type crosscheck
severity warning
runs against obs
scope whole dataset
requires oxygen_btl_ml_l, oxygen_ml_l_ave_sta_corr — absent means skip, never pass

Parameters

parameter value
bottle_type oxygen_btl_ml_l
sensor_type oxygen_ml_l_ave_sta_corr
threshold 0.3
units ml/L

THE classic CTD calibration check: sensor vs bottle reference at the same cast and depth. The bottle side is a lab measurement (Winkler oxygen, Portosal salinity, reversing-thermometer temperature) and is the closer thing to truth; a persistent offset means sensor drift, a bad calibration coefficient, or a mis-fired bottle.

This was impossible until the btl_* / *_btl types were made canonical — before that the reference side was excluded from obs entirely.

Bottle values are NOT depth-thinned (retained_reason = ‘bottle’ in the ingest), so the join is on exact depth: a bottle fires at discrete depths that the ~10 m grid would otherwise discard.

THE THRESHOLDS ARE OURS AND ARE PROVISIONAL — question calcofi_ctd-cast_10 asks CalCOFI to replace them with the tolerances they actually apply. They are set roughly an order of magnitude above each pair’s expected agreement, so they flag disagreement rather than measure it:

  temperature  0.5 degC     SBE 3plus initial accuracy is 0.001 degC and a
                            reversing thermometer is ~0.01, so anything at 0.5
                            is not calibration drift.
  salinity     0.05 PSS-78  a Portosal salinometer resolves ~0.001; 0.05 is a
                            bottle mis-fire, a mis-recorded depth, or a
                            conductivity cell problem.
  oxygen       0.3 mL/L     Winkler titration reproducibility is ~0.01-0.02
                            mL/L; SBE 43 accuracy is ~2% of saturation, which
                            near the surface is ~0.1 mL/L. 0.3 is beyond both.

WHAT A FLAG DOES NOT ESTABLISH: which side is wrong. A hit says the pair disagrees, not that the sensor drifted — a bottle fired at the wrong depth, a transcription error, or a sample analysed late all present identically here. The pattern is the evidence: a whole cast or cruise offset one way is sensor or calibration; scattered singletons are usually the bottle side.

Registry note. Winkler is the reference

WITH b AS (
  SELECT sample_key, depth_min_m, measurement_value AS v_bottle
  FROM obs
  WHERE dataset_key = 'calcofi_ctd-cast' AND measurement_type = '{{bottle_type}}'
), s AS (
  SELECT sample_key, depth_min_m, measurement_value AS v_sensor
  FROM obs
  WHERE dataset_key = 'calcofi_ctd-cast' AND measurement_type = '{{sensor_type}}'
)
SELECT
  b.sample_key                                                 AS subject_key,
  '{{bottle_type}} vs {{sensor_type}} differ by ' ||
    round(abs(b.v_bottle - s.v_sensor), 3) || ' {{units}} at ' ||
    b.depth_min_m || ' m'                                      AS detail,
  o.cruise_key,
  b.depth_min_m,
  -- the SENSOR type, not the bottle one: a bottle value is a single discrete
  -- point, so the profile a reviewer needs to see is the sensor's
  '{{sensor_type}}'                                            AS measurement_type,
  b.v_bottle,
  s.v_sensor,
  round(b.v_bottle - s.v_sensor, 4)                            AS offset_bottle_minus_sensor
FROM b
JOIN s USING (sample_key, depth_min_m)
JOIN (SELECT DISTINCT sample_key, cruise_key FROM obs
      WHERE dataset_key = 'calcofi_ctd-cast') o USING (sample_key)
WHERE abs(b.v_bottle - s.v_sensor) > {{threshold}}

ctd_bottle_sensor_offset_salinity

checks bottle vs sensor salinity offset at matched depth
type crosscheck
severity warning
runs against obs
scope whole dataset
requires salinity_btl, salinity_ave_corr — absent means skip, never pass

Parameters

parameter value
bottle_type salinity_btl
sensor_type salinity_ave_corr
threshold 0.05
units PSS-78

THE classic CTD calibration check: sensor vs bottle reference at the same cast and depth. The bottle side is a lab measurement (Winkler oxygen, Portosal salinity, reversing-thermometer temperature) and is the closer thing to truth; a persistent offset means sensor drift, a bad calibration coefficient, or a mis-fired bottle.

This was impossible until the btl_* / *_btl types were made canonical — before that the reference side was excluded from obs entirely.

Bottle values are NOT depth-thinned (retained_reason = ‘bottle’ in the ingest), so the join is on exact depth: a bottle fires at discrete depths that the ~10 m grid would otherwise discard.

THE THRESHOLDS ARE OURS AND ARE PROVISIONAL — question calcofi_ctd-cast_10 asks CalCOFI to replace them with the tolerances they actually apply. They are set roughly an order of magnitude above each pair’s expected agreement, so they flag disagreement rather than measure it:

  temperature  0.5 degC     SBE 3plus initial accuracy is 0.001 degC and a
                            reversing thermometer is ~0.01, so anything at 0.5
                            is not calibration drift.
  salinity     0.05 PSS-78  a Portosal salinometer resolves ~0.001; 0.05 is a
                            bottle mis-fire, a mis-recorded depth, or a
                            conductivity cell problem.
  oxygen       0.3 mL/L     Winkler titration reproducibility is ~0.01-0.02
                            mL/L; SBE 43 accuracy is ~2% of saturation, which
                            near the surface is ~0.1 mL/L. 0.3 is beyond both.

WHAT A FLAG DOES NOT ESTABLISH: which side is wrong. A hit says the pair disagrees, not that the sensor drifted — a bottle fired at the wrong depth, a transcription error, or a sample analysed late all present identically here. The pattern is the evidence: a whole cast or cruise offset one way is sensor or calibration; scattered singletons are usually the bottle side.

Registry note. Portosal is the reference

The query is metadata/qc_rules/sql/ctd_bottle_sensor_offset.sql, shown under ctd_bottle_sensor_offset_oxygen — this rule is the same check with different parameters.

ctd_bottle_sensor_offset_temperature

checks bottle vs sensor temperature offset at matched depth
type crosscheck
severity warning
runs against obs
scope whole dataset
requires btl_temperature, temperature_ave — absent means skip, never pass

Parameters

parameter value
bottle_type btl_temperature
sensor_type temperature_ave
threshold 0.5
units degC

THE classic CTD calibration check: sensor vs bottle reference at the same cast and depth. The bottle side is a lab measurement (Winkler oxygen, Portosal salinity, reversing-thermometer temperature) and is the closer thing to truth; a persistent offset means sensor drift, a bad calibration coefficient, or a mis-fired bottle.

This was impossible until the btl_* / *_btl types were made canonical — before that the reference side was excluded from obs entirely.

Bottle values are NOT depth-thinned (retained_reason = ‘bottle’ in the ingest), so the join is on exact depth: a bottle fires at discrete depths that the ~10 m grid would otherwise discard.

THE THRESHOLDS ARE OURS AND ARE PROVISIONAL — question calcofi_ctd-cast_10 asks CalCOFI to replace them with the tolerances they actually apply. They are set roughly an order of magnitude above each pair’s expected agreement, so they flag disagreement rather than measure it:

  temperature  0.5 degC     SBE 3plus initial accuracy is 0.001 degC and a
                            reversing thermometer is ~0.01, so anything at 0.5
                            is not calibration drift.
  salinity     0.05 PSS-78  a Portosal salinometer resolves ~0.001; 0.05 is a
                            bottle mis-fire, a mis-recorded depth, or a
                            conductivity cell problem.
  oxygen       0.3 mL/L     Winkler titration reproducibility is ~0.01-0.02
                            mL/L; SBE 43 accuracy is ~2% of saturation, which
                            near the surface is ~0.1 mL/L. 0.3 is beyond both.

WHAT A FLAG DOES NOT ESTABLISH: which side is wrong. A hit says the pair disagrees, not that the sensor drifted — a bottle fired at the wrong depth, a transcription error, or a sample analysed late all present identically here. The pattern is the evidence: a whole cast or cruise offset one way is sensor or calibration; scattered singletons are usually the bottle side.

Registry note. enabled by making btl_* canonical

The query is metadata/qc_rules/sql/ctd_bottle_sensor_offset.sql, shown under ctd_bottle_sensor_offset_oxygen — this rule is the same check with different parameters.

ctd_cast_correction_residual_oxygen

checks station-corrected oxygen does not reproduce the bottle oxygen it was fitted to (per cast)
type crosscheck
severity warning
runs against obs
scope whole dataset
requires oxygen_btl_ml_l, oxygen_ml_l_ave_sta_corr — absent means skip, never pass

Parameters

parameter value
bottle_type oxygen_btl_ml_l
sensor_type oxygen_ml_l_ave_sta_corr
depth_min 0
min_n 3
threshold 0.1
units ml/L

Does the published CORRECTED series still reproduce the bottle reference it was fitted to? This is a different question from ctd_bottle_sensor_offset, which flags one bottle-sensor pair at one depth. Here the unit is the CAST, and the statistic is the mean residual over all its matched pairs.

WHY THE MEAN, AND WHY THIS IS NOT AN INVENTED THRESHOLD. The source documents the correction scheme: _StaCorr fits a regression PER CAST from 1 m bin-averaged sensor data against that cast’s bottles, and salinity is offset from bottle salinities below 350 m. Both are fitted to make the per-cast residual vanish — so a cast where it has NOT vanished is a cast where the correction did not take, which is a far stronger statement than “these two disagree”.

Measured over the whole archive (245 CTDBTL files, 85 cruises, the ingest parquet of 2026-08-02), that is exactly what the data shows:

  oxygen_btl_ml_l vs oxygen_ml_l_ave_sta_corr, 5,330 casts with >= 3 pairs
    |mean residual| < 1e-4 mL/L : 83.1% of casts       <- fitted to zero
    p50 2.7e-05 | p90 0.032 | p95 0.087 | p99 0.574
  salinity_btl vs salinity_ave_corr below 350 m, 4,182 casts with >= 3 pairs
    p50 0.0021 | p90 0.0087 | p95 0.0148 | p99 0.106

The oxygen distribution is bimodal: a spike at machine zero and a tail. The threshold sits in the gap, not at a percentile chosen for taste. Salinity is not zeroed to machine precision (the offset is a single constant fitted with fliers omitted, and applied at all depths), but is an order of magnitude tighter than the point-wise tolerance.

The MEAN is deliberate and was checked against the alternative: taking the MEDIAN residual instead destroys the signal for oxygen (only 0.6% of casts fall within 1e-4, against 83.1% for the mean), which confirms the fit zeroes the mean over the bottles it used rather than a robust centre.

residual_sd IS THE CANDIDATE FOR measurement_prec. The core declares that column and no dataset populates it; the per-cast residual standard deviation is precisely the per-value uncertainty it exists to carry, and is what the provider’s own YYMM_DBcoeff_###-###.csv records as Salt1_SD / Ox1_StaCorrSD (question calcofi_ctd-cast_15). It is REPORTED here and acted on nowhere: until DBcoeff is ingested we cannot check our reconstruction against theirs, and populating measurement_prec from an unvalidated reconstruction would be worse than leaving it NULL.

WHAT A FLAG DOES NOT ESTABLISH: which side is wrong, same as the point-wise rule. A cast whose bottles were all analysed late, or whose bottle depths were mis-recorded, presents identically to one whose correction was never applied.

Registry note. threshold is NOT a percentile pick: the per-cast mean residual is zeroed by the _StaCorr fit, and 83.1% of 5,330 casts sit within 1e-4 ml/L of zero. 0.1 lands in the gap above that spike (4.3% of casts) and is 5-10x Winkler reproducibility. residual_sd is reported as the measurement_prec candidate but acted on nowhere - see calcofi_ctd-cast_15.

WITH pairs AS (
  SELECT b.sample_key,
         b.cruise_key,
         b.measurement_value - s.measurement_value AS residual
  FROM obs b
  JOIN obs s
    ON  s.sample_key   = b.sample_key
    AND s.depth_min_m  = b.depth_min_m
    AND s.dataset_key  = b.dataset_key
    AND s.measurement_type = '{{sensor_type}}'
  WHERE b.dataset_key      = 'calcofi_ctd-cast'
    AND b.measurement_type = '{{bottle_type}}'
    -- salinity is corrected from bottles BELOW 350 m only, so the check has to
    -- look where the fit looked; {{depth_min}} is 0 for the unrestricted pairs
    AND b.depth_min_m >= {{depth_min}}
), agg AS (
  SELECT sample_key, cruise_key,
         COUNT(*)                     AS n_pair,
         AVG(residual)                AS mean_residual,
         -- STDDEV_SAMP of a single row is NULL, which is why min_n is >= 3
         STDDEV_SAMP(residual)        AS residual_sd,
         MAX(ABS(residual))           AS max_abs_residual
  FROM pairs
  GROUP BY 1, 2
)
SELECT
  sample_key                                                    AS subject_key,
  'corrected {{sensor_type}} does not reproduce {{bottle_type}}: mean residual ' ||
    round(mean_residual, 4) || ' {{units}} over ' || n_pair ||
    ' bottle pairs (sd ' || round(COALESCE(residual_sd, 0), 4) || ')'
                                                                AS detail,
  cruise_key,
  -- the SENSOR type: that is the profile a reviewer needs on screen. No
  -- depth_min_m — the finding is a property of the cast, not of one scan, so the
  -- app opens the profile without ringing a depth.
  '{{sensor_type}}'                                             AS measurement_type,
  n_pair,
  round(mean_residual, 5)                                       AS mean_residual,
  round(residual_sd, 5)                                         AS residual_sd,
  round(max_abs_residual, 5)                                    AS max_abs_residual
FROM agg
WHERE n_pair >= {{min_n}}
  AND ABS(mean_residual) > {{threshold}}
ORDER BY ABS(mean_residual) DESC

ctd_cast_correction_residual_salinity

checks corrected salinity does not reproduce the below-350 m bottle salinity it was offset from (per cast)
type crosscheck
severity warning
runs against obs
scope whole dataset
requires salinity_btl, salinity_ave_corr — absent means skip, never pass

Parameters

parameter value
bottle_type salinity_btl
sensor_type salinity_ave_corr
depth_min 350
min_n 3
threshold 0.02
units PSS-78

Does the published CORRECTED series still reproduce the bottle reference it was fitted to? This is a different question from ctd_bottle_sensor_offset, which flags one bottle-sensor pair at one depth. Here the unit is the CAST, and the statistic is the mean residual over all its matched pairs.

WHY THE MEAN, AND WHY THIS IS NOT AN INVENTED THRESHOLD. The source documents the correction scheme: _StaCorr fits a regression PER CAST from 1 m bin-averaged sensor data against that cast’s bottles, and salinity is offset from bottle salinities below 350 m. Both are fitted to make the per-cast residual vanish — so a cast where it has NOT vanished is a cast where the correction did not take, which is a far stronger statement than “these two disagree”.

Measured over the whole archive (245 CTDBTL files, 85 cruises, the ingest parquet of 2026-08-02), that is exactly what the data shows:

  oxygen_btl_ml_l vs oxygen_ml_l_ave_sta_corr, 5,330 casts with >= 3 pairs
    |mean residual| < 1e-4 mL/L : 83.1% of casts       <- fitted to zero
    p50 2.7e-05 | p90 0.032 | p95 0.087 | p99 0.574
  salinity_btl vs salinity_ave_corr below 350 m, 4,182 casts with >= 3 pairs
    p50 0.0021 | p90 0.0087 | p95 0.0148 | p99 0.106

The oxygen distribution is bimodal: a spike at machine zero and a tail. The threshold sits in the gap, not at a percentile chosen for taste. Salinity is not zeroed to machine precision (the offset is a single constant fitted with fliers omitted, and applied at all depths), but is an order of magnitude tighter than the point-wise tolerance.

The MEAN is deliberate and was checked against the alternative: taking the MEDIAN residual instead destroys the signal for oxygen (only 0.6% of casts fall within 1e-4, against 83.1% for the mean), which confirms the fit zeroes the mean over the bottles it used rather than a robust centre.

residual_sd IS THE CANDIDATE FOR measurement_prec. The core declares that column and no dataset populates it; the per-cast residual standard deviation is precisely the per-value uncertainty it exists to carry, and is what the provider’s own YYMM_DBcoeff_###-###.csv records as Salt1_SD / Ox1_StaCorrSD (question calcofi_ctd-cast_15). It is REPORTED here and acted on nowhere: until DBcoeff is ingested we cannot check our reconstruction against theirs, and populating measurement_prec from an unvalidated reconstruction would be worse than leaving it NULL.

WHAT A FLAG DOES NOT ESTABLISH: which side is wrong, same as the point-wise rule. A cast whose bottles were all analysed late, or whose bottle depths were mis-recorded, presents identically to one whose correction was never applied.

Registry note. depth_min=350 because the source derives the salinity offset from bottle salinities BELOW 350 m and applies it at all depths - the check has to look where the fit looked. Observed over 4,182 casts: p50 0.0021, p95 0.0148. 0.02 flags 3.3% and is ~20x a Portosal’s resolution. Most casts have exactly 3 deep bottles, so min_n=3 is the floor, not a preference.

The query is metadata/qc_rules/sql/ctd_cast_correction_residual.sql, shown under ctd_cast_correction_residual_oxygen — this rule is the same check with different parameters.

ctd_cast_deeper_than_seafloor

checks cast measures below the GEBCO seafloor at its position
type crosscheck
severity warning
runs against sample
scope whole dataset

Parameters

parameter value
tolerance_m 50

A cast cannot measure below the seafloor.

Compares each cast’s deepest observation against GEBCO 2025 bathymetry sampled at the cast position (materialized as sample_seafloor by ctd-qaqc’s prep_db.R, reusing the cropped raster apps/ctd-viz already maintains).

CTD casts carry NO reported bottom depth — bottom_depth exists in sample_measurement for 33,363 BOTTLE casts but for 0 of 14,336 CTD casts — so a bathymetry model is the only available reference here.

EXPECT THIS TO PASS, and value it anyway. Measured against the current data the agreement is already excellent: median cast stops ~982 m ABOVE the seafloor, the 99th percentile excess is +4 m and the maximum is +52 m. This is a regression guard — a depth unit error, a sign flip, or a position corruption would blow it up immediately, and none of those is otherwise detectable.

The tolerance absorbs GEBCO’s own uncertainty: the raster is ~0.0042 deg (~460 m) per cell, so a cast on a steep slope can legitimately read deeper than the cell average. 88 casts exceed the seafloor by any amount; only 2 exceed it by more than 50 m.

Registry note. regression guard; currently 2 casts, both ~51 m over — within what GEBCO cell averaging on a slope can explain. severity is warning, not error, because exceeding a coarse bathymetry model is suggestive, not proof. a depth unit error or sign flip would blow this up by orders of magnitude.

WITH d AS (
  SELECT
    o.sample_key,
    MAX(o.depth_min_m)      AS max_measured_depth_m,
    ANY_VALUE(o.cruise_key) AS cruise_key
  FROM obs o
  WHERE o.dataset_key = 'calcofi_ctd-cast' AND o.depth_min_m IS NOT NULL
  GROUP BY 1
)
SELECT
  d.sample_key                                                  AS subject_key,
  'deepest measurement ' || round(d.max_measured_depth_m, 1) ||
    ' m is ' || round(d.max_measured_depth_m - f.seafloor_depth_m, 1) ||
    ' m below the GEBCO seafloor (' || round(f.seafloor_depth_m, 1) || ' m)'
                                                                AS detail,
  d.cruise_key, s.site_key,
  round(d.max_measured_depth_m, 1)                              AS max_measured_depth_m,
  round(f.seafloor_depth_m, 1)                                  AS seafloor_depth_m,
  round(d.max_measured_depth_m - f.seafloor_depth_m, 1)         AS excess_m
FROM d
JOIN sample_seafloor f USING (sample_key)
JOIN sample s          USING (sample_key)
WHERE d.max_measured_depth_m - f.seafloor_depth_m > {{tolerance_m}}

ctd_cast_position_vs_station

checks seafloor at the recorded position disagrees with the station average
type crosscheck
severity warning
runs against sample
scope whole dataset
ported from Access master query TQ - BottomDepth_Vs_AvgBottomDepth

Parameters

parameter value
tolerance_m 500

Is the cast where it says it is?

Adapted from the Access master’s TQ - BottomDepth_Vs_AvgBottomDepth, which flagged casts whose bottom depth differed from the station’s average by more than 500 m. THE SEMANTICS DIFFER and that matters: the original compared the ship’s ECHOSOUNDER reading to the station average, catching both a bad sounder and a mispositioned cast. CTD casts have no reported bottom depth, so this compares GEBCO bathymetry AT THE RECORDED POSITION to the station average — which tests position plausibility only. A cast logged at the wrong station, or with a corrupted fix, lands in water of the wrong depth; a bad sounder reading is invisible to this form.

The +/-500 m threshold is carried over from the Access original deliberately, so results stay comparable to three decades of prior review. Measured on the current data: median |difference| is 17 m, 90th percentile 76 m, 99th 190 m — so 500 m is a genuine outlier, not a routine one. 6 casts exceed it.

Only the 75 current standard stations have an average depth on file, so casts at other sites are not evaluated rather than being assumed fine.

Registry note. adapted: tests POSITION plausibility via GEBCO, not echosounder accuracy. 500 m threshold kept from the Access original

SELECT
  s.sample_key                                                  AS subject_key,
  'seafloor at the recorded position is ' || round(f.seafloor_depth_m, 1) ||
    ' m but station ' || s.site_key || ' averages ' ||
    round(st.avg_bottom_depth_m, 1) || ' m (' ||
    round(f.seafloor_depth_m - st.avg_bottom_depth_m, 1) || ' m off)'
                                                                AS detail,
  s.cruise_key, s.site_key,
  round(s.longitude, 4)                                         AS longitude,
  round(s.latitude, 4)                                          AS latitude,
  round(f.seafloor_depth_m, 1)                                  AS seafloor_depth_m,
  round(st.avg_bottom_depth_m, 1)                               AS station_avg_depth_m,
  round(f.seafloor_depth_m - st.avg_bottom_depth_m, 1)          AS difference_m
FROM sample s
JOIN sample_seafloor f USING (sample_key)
JOIN station st ON s.site_key = st.site_key
WHERE s.dataset_key = 'calcofi_ctd-cast'
  AND s.sample_type = 'cast'
  AND abs(f.seafloor_depth_m - st.avg_bottom_depth_m) > {{tolerance_m}}

ctd_updown_disagreement

checks down- and upcast disagree at the same depth
type crosscheck
severity warning
runs against obs_ctd_full
scope one cruise at a time (reads the full-resolution scans)

Parameters

parameter value
measurement_type temperature_ave
threshold 1.0

Down- and upcast disagree at the same depth.

The two directions sample the same water column minutes apart, so a large difference means sensor lag, a thermal-mass artefact, fouling, or genuine internal-wave motion. ctd_thin keeps ONE direction per physical cast, so this signal is invisible in the headline table and only exists in obs_ctd_full.

Pairing is by stripping the trailing direction character from sample_key. Note the trap: a naive replace(sample_key,‘d’,’‘) also mangles the ’calcofi_ctd-cast’ prefix, so this uses left(…, length-1).

Depths are rounded to 1 m before matching: the two directions never sample the exact same depth, and the source is already ~1 m bin-averaged.

Measured on 2023-11-33P4, temperature, 27,274 matched depths: median difference 0.026 degC, 99th percentile 0.90, maximum 46.1 — so the tail is dramatic and worth surfacing, while the bulk is tight.

Registry note. invisible in ctd_thin, which keeps one direction per physical cast

WITH b AS (
  SELECT
    left(sample_key, length(sample_key) - 1)                       AS cast_base,
    right(sample_key, 1)                                           AS cast_dir,
    ANY_VALUE(cruise_key)                                          AS cruise_key,
    ROUND(depth_min_m)                                             AS depth_m,
    AVG(measurement_value)                                         AS v
  FROM obs_ctd_full
  WHERE cruise_key       = '{{cruise_key}}'
    AND measurement_type = '{{measurement_type}}'
    AND measurement_value IS NOT NULL
  GROUP BY 1, 2, 4
)
SELECT
  d.cast_base || 'd'                                               AS subject_key,
  '{{measurement_type}} differs by ' || round(abs(d.v - u.v), 3) ||
    ' between down- and upcast at ' || d.depth_m || ' m'           AS detail,
  d.cruise_key,
  d.depth_m                                                        AS depth_min_m,
  '{{measurement_type}}'                                           AS measurement_type,
  round(d.v, 4)                                                    AS value_down,
  round(u.v, 4)                                                    AS value_up,
  round(d.v - u.v, 4)                                              AS difference
FROM      (SELECT * FROM b WHERE cast_dir = 'd') d
JOIN      (SELECT * FROM b WHERE cast_dir = 'u') u USING (cast_base, depth_m)
WHERE abs(d.v - u.v) > {{threshold}}

Fk rules

ctd_obs_orphan_sample

checks obs row references a sample_key absent from sample
type fk
severity error
runs against obs
scope whole dataset
ported from the Access master’s TR - test family

Referential integrity: every obs must resolve to a sample. Ported from the Access master’s TR - family (“test for proper linkage between tables”), rewritten against the core model.

Registry note. ported from the Access TR family (table-linkage tests)

SELECT
  o.sample_key                                                 AS subject_key,
  'obs row references a sample_key with no row in sample'      AS detail,
  o.cruise_key, o.measurement_type, o.depth_min_m
FROM obs o
LEFT JOIN sample s USING (sample_key)
WHERE o.dataset_key = 'calcofi_ctd-cast'
  AND s.sample_key IS NULL

Lookup rules

ctd_qual_unrecognized

checks quality code absent from the controlled vocabulary
type lookup
severity warning
runs against obs
scope whole dataset

Quality codes absent from the controlled vocabulary.

THE VOCABULARY IS DATASET-SCOPED, and getting that wrong was a real error here. It was first recovered from the CalCOFI hydrographic master (the BOTTLE database: 6 = data OK but taken from CTD, 8 = suspect, 9 = missing), and the CTD files use a DIFFERENT set — documented all along in CTD-CSV-Format.pdf, which ships inside every source zip:

  0 or blank  good data ("if no data code is displayed then sensors were
              operating normally")
  1           USE PRIMARY sensor data      <- a sensor-selection instruction,
  2           USE SECONDARY sensor data       NOT a quality grade
  8           data questionable
  9           bad OR missing sensor data

Two consequences worth stating. Codes 1 and 2 do not rank a value at all — they say which half of a dual-sensor pair to believe, so treating them as “mildly good” is a category error. And 9 means bad or missing, so a row flagged 9 that carries a number is bad data rather than the contradiction it first looks like.

measurement_qual.csv therefore carries a code_set column, and this rule filters to the CTD set. Joining the whole file would let a bottle-only code mask a genuinely unrecognized CTD one.

A code outside the set means either an undocumented convention or corruption, and must not be silently treated as “some flag”.

A PASS HERE IS ALMOST EMPTY OF MEANING, and that is the finding. Only 8 of the 27 published measurement types carry any flag at all (isus_v, ph, par, transmissometer, fluorescence_v, sigma_theta_1, spar, pressure), covering 3,947 of 7.3M rows — 0.05%. The canonical temperature, salinity and oxygen types have no quality column whatsoever, because the source flags attach to the component sensors (Temp1Q, Salt1Q, Ox1Q, Ox2Q) while the canonical types are the averages of them (question calcofi_ctd-cast_09). So this rule tests that the few flags present use known codes; it cannot tell you whether a headline value is good.

It also does not judge the flag. Every one of the 3,052 rows flagged 9 = “missing data” nevertheless carries a value, some of them impossible — a contradiction this rule passes over, because 9 is in the vocabulary (question calcofi_ctd-cast_13).

Registry note. vocabulary recovered from the Access master in Phase 2

SELECT
  o.sample_key                                                 AS subject_key,
  'unrecognized quality code "' || o.measurement_qual || '" on '
    || o.measurement_type                                      AS detail,
  o.cruise_key, o.measurement_type, o.depth_min_m, o.measurement_qual
FROM obs o
LEFT JOIN (SELECT * FROM measurement_qual WHERE code_set = 'ctd') q
       ON o.measurement_qual = q.qual_code
WHERE o.dataset_key = 'calcofi_ctd-cast'
  AND o.measurement_qual IS NOT NULL
  AND q.qual_code IS NULL

Profile rules

ctd_density_inversion

checks sigma-theta decreases with depth
type profile
severity warning
runs against obs
scope whole dataset
requires sigma_theta_1 — absent means skip, never pass

Parameters

parameter value
threshold 0.05

Density must not decrease with depth. A sigma-theta inversion is either a real (rare, shallow, transient) feature or — far more often — a spike, a mis-ordered scan, or a salinity glitch propagating into the derived density.

A CTD-specific test with no counterpart in the bottle-era Access master, whose checks are all range- and linkage-based at bottle grain.

THE 0.05 kg/m3 THRESHOLD IS OURS, not a CalCOFI or QARTOD standard, and it is set from the observed distribution rather than from theory. Across the thinned profiles the overwhelming majority of successive sigma-theta differences are positive; of the negative ones, almost all are smaller than 0.01 kg/m3, which is the scale of rounding and of the sensor’s own resolution on a near-neutral layer. 0.05 sits an order of magnitude above that floor, so it selects inversions that no amount of rounding explains — 1,072 of 495,537 sigma-theta observations, ~0.2%.

WHAT IT CANNOT SEE: this runs on ctd_thin, which retains roughly one sample per 10 m. A genuine single-scan density inversion between retained depths is invisible here; the profile rules on obs_ctd_full are where that would surface. Nor does a pass mean the water column is stable — only that it is monotonic at the depths retained.

Registry note. CTD-specific; no counterpart in the bottle-era master

WITH p AS (
  SELECT
    sample_key, cruise_key, depth_min_m,
    measurement_value AS sigma,
    LAG(measurement_value) OVER (
      PARTITION BY sample_key ORDER BY depth_min_m) AS sigma_above,
    LAG(depth_min_m) OVER (
      PARTITION BY sample_key ORDER BY depth_min_m) AS depth_above
  FROM obs
  WHERE dataset_key = 'calcofi_ctd-cast'
    AND measurement_type = 'sigma_theta_1'
    AND measurement_value IS NOT NULL
)
SELECT
  sample_key                                                   AS subject_key,
  'sigma-theta drops ' || round(sigma_above - sigma, 3) ||
    ' kg/m3 between ' || depth_above || ' and ' || depth_min_m || ' m' AS detail,
  cruise_key, depth_above, depth_min_m,
  'sigma_theta_1'                                              AS measurement_type,
  sigma_above, sigma,
  round(sigma_above - sigma, 4)                                AS inversion_magnitude
FROM p
WHERE sigma_above IS NOT NULL
  AND sigma_above - sigma > {{threshold}}

ctd_pressure_monotonic

checks depth reverses within a cast direction (loop edit)
type profile
severity warning
runs against obs_ctd_full
scope one cruise at a time (reads the full-resolution scans)

Parameters

parameter value
min_reversal_m 1

Depth reversal within a single cast direction — a loop edit.

A downcast should descend monotonically IN TIME; when the ship heaves, the package can rise and re-sample water it already passed through, which corrupts the profile. Seasoft’s bin-averaging normally removes these, so survivors are worth seeing.

ORDERING IS BY datetime, NOT obs_id. This matters: obs_id does not follow scan order (measured: 27,272 of 54,505 consecutive obs_id pairs have DECREASING depth, i.e. ~50%, which is what you get from an unrelated sort). datetime is genuinely per-scan — 55 distinct timestamps for 55 scans in one cast — so it is the only recoverable scan sequence. Ordering by obs_id would report every cast as a mass of reversals.

Direction comes from the sample_key suffix (…001d / …001u), which pairs perfectly (70 down / 70 up on 2023-11-33P4).

Measured reversals per cruise: 2 (2023-11-33P4), 10 (1998-07-32NM), 19 (2013-01-3322); largest single reversal 59 m.

min_reversal_m = 1 is a NOISE FLOOR, not a tolerance for real heave. The source is ~1 m bin-averaged, so successive scans differ by about a metre anyway and a threshold below that would report the binning itself as a loop edit. Anything surviving 1 m is a reversal larger than the data’s own resolution.

WHAT IT CANNOT SEE: heave smaller than the bin size, and any loop that Seasoft already removed — which is most of them. A low count is therefore the expected result and not evidence that the package never moved.

Registry note. orders by datetime, NOT obs_id — obs_id is not scan order (~50% of consecutive pairs decrease)

WITH x AS (
  SELECT
    sample_key, cruise_key, datetime, depth_min_m,
    right(sample_key, 1)                                           AS cast_dir,
    LAG(depth_min_m) OVER (PARTITION BY sample_key ORDER BY datetime, depth_min_m) AS depth_prev,
    LAG(datetime)    OVER (PARTITION BY sample_key ORDER BY datetime, depth_min_m) AS datetime_prev
  FROM obs_ctd_full
  WHERE cruise_key       = '{{cruise_key}}'
    AND measurement_type = 'temperature_ave'
    AND depth_min_m IS NOT NULL
)
SELECT
  sample_key                                                       AS subject_key,
  CASE cast_dir WHEN 'd' THEN 'downcast rose ' ELSE 'upcast sank ' END ||
    round(abs(depth_prev - depth_min_m), 1) || ' m (from ' ||
    round(depth_prev, 1) || ' to ' || round(depth_min_m, 1) || ' m)' AS detail,
  cruise_key, cast_dir, datetime_prev, datetime,
  round(depth_prev, 1)                                             AS depth_prev_m,
  round(depth_min_m, 1)                                            AS depth_min_m,
  'pressure'                                                       AS measurement_type,
  round(abs(depth_prev - depth_min_m), 1)                          AS reversal_m
FROM x
WHERE depth_prev IS NOT NULL
  AND (   (cast_dir = 'd' AND depth_prev - depth_min_m > {{min_reversal_m}})
       OR (cast_dir = 'u' AND depth_min_m - depth_prev > {{min_reversal_m}}))

ctd_spike_salinity

checks single-scan salinity spike against a locally smooth profile
type profile
severity warning
runs against obs_ctd_full
scope one cruise at a time (reads the full-resolution scans)

Parameters

parameter value
measurement_type salinity_ave_corr
spike_threshold 0.1
neighbour_tol 0.1

Single-scan spike against a locally smooth profile.

Runs against obs_ctd_full — the full-resolution scans — because a spike is by definition a single-scan excursion and ctd_thin has already discarded most scans. Cruise-scoped: obs_ctd_full is 212M rows, hive-partitioned by cruise_key, so one cruise prunes to ~2M rows and returns in well under a second, while an unscoped run would scan the lot.

NEIGHBOUR AGREEMENT IS THE WHOLE TRICK. The naive test — value differs from the midpoint of its neighbours — also fires on every steep-but-smooth gradient, and a CTD profile through a thermocline is nothing but steep smooth gradients. So a point qualifies only if it deviates from the neighbour midpoint by more than {{spike_threshold}} WHILE the neighbours agree with each other within {{neighbour_tol}}: the profile is locally smooth and this one scan is not.

Measured on cruise 2023-11-33P4, temperature, 54,365 scans:

  naive |v - (a+b)/2| > 0.5           92 scans
  + neighbours agree within 0.5       19 scans   <- 73 were real gradients
  thresholds at 2.0                    3 scans
WITH x AS (
  SELECT
    sample_key, cruise_key, depth_min_m, datetime,
    measurement_value                                              AS v,
    LAG(measurement_value)  OVER (PARTITION BY sample_key ORDER BY depth_min_m) AS v_above,
    LEAD(measurement_value) OVER (PARTITION BY sample_key ORDER BY depth_min_m) AS v_below
  FROM obs_ctd_full
  WHERE cruise_key       = '{{cruise_key}}'
    AND measurement_type = '{{measurement_type}}'
    AND measurement_value IS NOT NULL
)
SELECT
  sample_key                                                       AS subject_key,
  '{{measurement_type}} spike at ' || round(depth_min_m, 1) || ' m: ' ||
    round(v, 3) || ' between ' || round(v_above, 3) || ' and ' ||
    round(v_below, 3)                                              AS detail,
  cruise_key, depth_min_m, datetime,
  '{{measurement_type}}'                                           AS measurement_type,
  round(v, 4)                                                      AS value,
  round(v_above, 4)                                                AS value_above,
  round(v_below, 4)                                                AS value_below,
  round(abs(v - (v_above + v_below) / 2), 4)                       AS excursion,
  round(abs(v_above - v_below), 4)                                 AS neighbour_gap
FROM x
WHERE v_above IS NOT NULL AND v_below IS NOT NULL
  AND abs(v - (v_above + v_below) / 2) > {{spike_threshold}}
  AND abs(v_above - v_below)           < {{neighbour_tol}}

ctd_spike_temperature

checks single-scan temperature spike against a locally smooth profile
type profile
severity warning
runs against obs_ctd_full
scope one cruise at a time (reads the full-resolution scans)

Parameters

parameter value
measurement_type temperature_ave
spike_threshold 0.5
neighbour_tol 0.5

Single-scan spike against a locally smooth profile.

Runs against obs_ctd_full — the full-resolution scans — because a spike is by definition a single-scan excursion and ctd_thin has already discarded most scans. Cruise-scoped: obs_ctd_full is 212M rows, hive-partitioned by cruise_key, so one cruise prunes to ~2M rows and returns in well under a second, while an unscoped run would scan the lot.

NEIGHBOUR AGREEMENT IS THE WHOLE TRICK. The naive test — value differs from the midpoint of its neighbours — also fires on every steep-but-smooth gradient, and a CTD profile through a thermocline is nothing but steep smooth gradients. So a point qualifies only if it deviates from the neighbour midpoint by more than {{spike_threshold}} WHILE the neighbours agree with each other within {{neighbour_tol}}: the profile is locally smooth and this one scan is not.

Measured on cruise 2023-11-33P4, temperature, 54,365 scans:

  naive |v - (a+b)/2| > 0.5           92 scans
  + neighbours agree within 0.5       19 scans   <- 73 were real gradients
  thresholds at 2.0                    3 scans

Registry note. neighbour-agreement criterion; naive test flagged 92 on 2023-11-33P4, this keeps 19

The query is metadata/qc_rules/sql/ctd_spike.sql, shown under ctd_spike_salinity — this rule is the same check with different parameters.

Range rules

ctd_value_out_of_range

checks measurement outside its declared valid_min/valid_max
type range
severity warning
runs against obs
scope whole dataset
ported from the Access master’s TV - test family

Values outside the declared plausible physical range. Ranges live in metadata/measurement_type.csv (valid_min / valid_max), moved there from an inline tribble so they are reviewable and reusable. Generous by design: this catches impossible values, it does not police oceanography.

COVERAGE IS PARTIAL AND A PASS MUST NOT BE READ AS “ALL VALUES ARE SANE”. 18 of the 27 measurement types in the published CTD obs carry a declared range; the other 9 are never evaluated by this rule — beam_attenuation, btl_depth, dynamic_height, fluorescence_v, isus_v, par, spar, specific_volume_anomaly, transmissometer. Most are raw sensor voltages or derived quantities with no agreed physical bound, which is why they have none rather than an oversight. The rule reports one row per violating value, so a single bad cruise dominates the count: read it alongside the per-type and per-cruise breakdowns, not as a single number.

The ranges are OURS, not CalCOFI’s, and question calcofi_ctd-cast_02 asks the providers to confirm or replace them. Nothing is dropped on the strength of them.

Registry note. ranges live in measurement_type.csv; generous by design

SELECT
  o.sample_key                                                 AS subject_key,
  o.measurement_type || ' = ' || o.measurement_value ||
    ' outside [' || m.valid_min || ', ' || m.valid_max || ']'  AS detail,
  o.cruise_key, o.measurement_type, o.depth_min_m,
  o.measurement_value, m.valid_min, m.valid_max
FROM obs o
JOIN measurement_type m USING (measurement_type)
WHERE o.dataset_key = 'calcofi_ctd-cast'
  AND m.valid_min IS NOT NULL
  AND (o.measurement_value < m.valid_min OR o.measurement_value > m.valid_max)

ctd_value_outside_type_depth_range

checks value recorded outside the depth range over which its measurement type is defined
type range
severity warning
runs against obs_ctd_full
scope one cruise at a time (reads the full-resolution scans)

A value recorded outside the depth range over which its measurement type is DEFINED. Not a range check on the value — a check that the type exists at all at that depth.

Driven entirely by valid_depth_min_m / valid_depth_max_m in metadata/measurement_type.csv, so it covers whatever the registry declares rather than naming any type here. Today exactly one family declares a range:

  est_chlorophyll_a_cruise_corr / _sta_corr : 0 - 200 m

CTD-CSV-Format.pdf states the fluorometer regression is applied to the 1 m bin-averaged voltage over 0-200 m ONLY, so EstChl_* is undefined deeper by CONSTRUCTION rather than by absence of data. That distinction had nowhere to live until the registry gained the columns, and a completeness check reading a null at 300 m would have called it missing data.

THIS RULE CURRENTLY REPORTS ZERO, AND THAT IS THE POINT. Measured against obs_ctd_full on 2026-08-02:

  est_chlorophyll_a_cruise_corr  1,993,043 values, max depth 200.0 m, 0 below
  est_chlorophyll_a_sta_corr     2,243,906 values, max depth 200.0 m, 0 below

The data honours the documented restriction exactly, which is what licensed writing 0/200 into the registry in the first place. Keeping the rule active is what stops that silently ceasing to be true — the same standing-guard role ctd_sentinel_neg99 plays for the -99 sentinel.

For contrast, est_nitrate_* is 60% below 200 m and reaches 3,498 m, so it declares no depth range and this rule correctly ignores it. The restriction is specific to the chlorophyll regression, not to derived types in general.

Requires a measurement_type reference carrying the two columns, which means a registry or release from v2026.08 on; against an older one the rule ERRORS rather than passing, which is the correct failure.

Cruise-scoped like its obs_ctd_full peers: 212M rows is a sweep the app runs one cruise at a time, not something to run inline.

Registry note. Registry-driven, names no type. Only est_chlorophyll_a_* declares a range (0-200 m, per CTD-CSV-Format.pdf). Reports ZERO today and that is the point: measured 2026-08-02, both EstChl types max out at exactly 200.0 m with 0 values below, so this is a standing regression guard like ctd_sentinel_neg99. est_nitrate_* is 60% below 200 m to 3,498 m and correctly declares no range. Needs measurement_type carrying valid_depth_min_m/max_m — a registry or release from v2026.08 on; older ones ERROR rather than pass.

SELECT
  o.sample_key                                                  AS subject_key,
  o.measurement_type || ' recorded at ' || round(o.depth_min_m, 1) ||
    ' m, outside the ' ||
    COALESCE(CAST(round(mt.valid_depth_min_m, 1) AS VARCHAR), '-inf') || ' to ' ||
    COALESCE(CAST(round(mt.valid_depth_max_m, 1) AS VARCHAR), 'inf') ||
    ' m range over which the type is defined'                   AS detail,
  o.cruise_key,
  o.depth_min_m,
  o.measurement_type,
  o.measurement_value
FROM obs_ctd_full o
JOIN measurement_type mt USING (measurement_type)
WHERE o.cruise_key = '{{cruise_key}}'
  AND o.measurement_value IS NOT NULL
  AND (   (mt.valid_depth_min_m IS NOT NULL AND o.depth_min_m < mt.valid_depth_min_m)
       OR (mt.valid_depth_max_m IS NOT NULL AND o.depth_min_m > mt.valid_depth_max_m))
ORDER BY o.depth_min_m DESC

Sentinel rules

ctd_sentinel_neg99

checks -99 missing-value sentinel served as a real measurement
type sentinel
severity error
runs against obs
scope whole dataset

Regression guard for the -99 sentinel fix (ingest_calcofi_ctd-cast.qmd). -99 is this source’s documented missing marker. It was stripped from longitude/latitude only, so it flowed into released measurements as a real reading (84,302 rows in v2026.07.17, incl. canonical oxygen). Expected: 0.

MATCHED EXACTLY (= -99), not by tolerance: every observed case was exactly -99.00, and an exact test cannot swallow a real reading that merely rounds near it. Two types are genuinely signed (dynamic_height, specific_volume_anomaly), so a real -99 there would be reported rather than assumed away.

ONLY -99 IS CHECKED. Other conventional markers (-999, -9.99) are not searched for here, and -9.99e-29 is handled separately as a pseudo-NA in the ingest. A pass means this sentinel is gone, not that the source carries no others.

Registry note. regression guard for the ingest fix; expected count is 0

SELECT
  sample_key                                                   AS subject_key,
  measurement_type || ' = -99 at ' || depth_min_m || ' m'       AS detail,
  cruise_key, measurement_type, depth_min_m, measurement_value
FROM obs
WHERE dataset_key = 'calcofi_ctd-cast'
  AND measurement_value = -99

Parked rules

Registered, documented, and deliberately not running. They are kept in the registry rather than deleted so that the reason survives — a rule that quietly disappears looks like a rule that was never considered.

Code
rules_off <- filter(rules_all, !active)
md_parked <- if (nrow(rules_off))
  qc_protocol_markdown(rules_off, group_by = NULL) else
    "*No parked rules.*\n"
cat(md_parked)

ctd_bottom_depth_vs_station

checks cast bottom depth deviates >500 m from the station average
type crosscheck
severity warning
runs against sample
scope whole dataset
ported from Access master query TQ - BottomDepth_Vs_AvgBottomDepth
status parked — not run

No query is written for this rule. The reason it is parked rather than implemented is the registry note below — which is the point of keeping it registered.

Registry note. SUPERSEDED by ctd_cast_position_vs_station. A faithful port needs the ships echosounder bottom depth, which exists in sample_measurement for 33,363 BOTTLE casts but 0 of 14,336 CTD casts. The faithful form belongs in a bottle QC pass.

ctd_sensor1_vs_sensor2

checks dual-sensor disagreement beyond tolerance
type crosscheck
severity warning
runs against obs
scope whole dataset
status parked — not run

No query is written for this rule. The reason it is parked rather than implemented is the registry note below — which is the point of keeping it registered.

Registry note. TODO Phase 6 — thresholds per property not yet agreed

ctd_value_outside_cast_span

checks measurement outside the min/max the processor recorded for its own cast
type range
severity warning
runs against obs
scope whole dataset
status parked — not run

No query is written for this rule. The reason it is parked rather than implemented is the registry note below — which is the point of keeping it registered.

Registry note. PARKED pending question calcofi_ctd-cast_15, now proposed: we surveyed the source files on 2026-08-02 and recommend ingesting them. Every cruise ships YYMM_span_###-###.csv giving per-CAST min/max for every variable as recorded by BtlVsCTD - 136 files, 16,254 rows, but 70 distinct headers (61-79 columns) because the column set follows each cruise’s sensor suite, so it needs a long (cast, variable, min, max) melt rather than a fixed schema. That is a far stronger range check than ctd_value_out_of_range, whose valid_min/valid_max are ours and generous by design: a value outside the span its own processor recorded means the released value is not the value that was processed. It is also the only NON-CIRCULAR version of this check - recomputing the span from the values we published cannot disagree with itself, which is why the ctd-qaqc app shows a recomputed span as review context only and labels it as such.

Standards and prior art

This protocol is deliberately small and CalCOFI-specific. It is not a reimplementation of the community standards, and where it diverges from them that is worth stating.

QARTOD

The IOOS Manual for Real-Time Quality Control of In-situ Temperature and Salinity Observations (and its dissolved-oxygen and ocean-optics companions) is the community reference for profile QC, and its 1 / 2 / 3 / 4 / 9 flags (pass / not evaluated / suspect / fail / missing) are what an ERDDAP consumer expects to find. Several tests here have direct QARTOD counterparts — gross range (QARTOD test 6), spike (9), rate of change (10), flat line (11), density inversion (15) — though the thresholds are ours and derived from this dataset rather than adopted from the manual.

CalCOFI CTD is published to erddap.calcofi.io, where the native codes currently pass through uninterpreted, and one of them (6 = “data OK but taken from CTD sensor”) is meaningless for a CTD dataset. Emitting QARTOD flags alongside the native codes is question calcofi_ctd-cast_11, and is the natural home for the results of these rules once the flag semantics above are settled.

oce — Dan Kelley

The reference R package for oceanographic data, and the closest prior art for the profile rules here: read.ctd(), ctdTrim() (removes the soak and the upcast), despike(), and ctdDecimate() (bins a profile to standard depths). Anyone implementing these checks should read the oce CTD vignette and Analysis of Oceanographic Data alongside this document.

Two deliberate differences. ctdTrim() discards the upcast; we keep it, in obs_ctd_full, precisely so the down/up comparison is available as a check — the disagreement is signal about sensor lag and fouling, not noise to be trimmed. And ctdDecimate() resamples onto a regular grid by interpolation, whereas ctd_thin selects real scans and never interpolates, so a thinned value is always a measurement that was actually taken.

castr — Jean-Olivier Irisson

Cast-oriented profile utilities: smoothing, despiking, and detection of the mixed layer depth, deep chlorophyll maximum and thermocline. Relevant twice over — as a comparison for the despiking approach, and because the Access master’s MLD_Sigma and NutClineDepth tables are exactly the derived products castr computes. Those two tables are committed under metadata/calcofi/hydro-master/reference/ unused, so that any reimplementation can be validated against the Access-era numbers rather than trusted.

WarningThe CRAN package named CTD is not oceanographic

CTD on CRAN is “Connecting The Dots” — a weighted-graph pattern-discovery method for metabolomics. It has nothing to do with conductivity-temperature-depth profiles. It is an easy citation to make by name-matching and a hard one to notice afterwards, so: do not cite it here.

What is not checked

Stated so that a clean run is not over-read.

  • The headline variables carry no quality flags at all. Source flags attach to the component sensors (Temp1Q, Salt1Q, Ox1Q, Ox2Q); the canonical types are the averages of them, so the flag is lost in the mean. Only 8 of 27 published types carry any flag, over 0.05% of rows. How a sensor flag should qualify an average of two is question calcofi_ctd-cast_09 — a blocker.
  • Range coverage is partial. 18 of the 27 published types have a declared valid_min/valid_max; the rest — mostly raw voltages and derived quantities with no agreed bound — are never range-checked.
  • The profile rules are sampled, not swept. Spike, loop-edit and up/down disagreement run one cruise at a time against obs_ctd_full; a whole-archive sweep is the ctd-qaqc app’s job, not this document’s.
  • Dual-sensor disagreement is parked. The thresholds per property are not agreed, and a rule with a guessed threshold is worse than a rule that says it is not running.
  • No rule drops, rewrites or interpolates a value. Every result is a report. Two blocker questions remain open with the data providers, and acting on them before they are answered would be guessing with released data.

Coverage

The build fails if a rule that runs is not described here, or carries no rationale in its SQL header. A protocol that silently omits a check is worse than no protocol, because it reads as completeness.

Code
qc_protocol_check(rules_all, paste(md_active, md_parked))

say(glue("active rules documented : {nrow(rules_act)}/{nrow(rules_act)}"))
active rules documented : 19/19
Code
say(glue("parked rules documented : {nrow(rules_off)}/{nrow(rules_off)}"))
parked rules documented : 3/3
Code
say(glue("every active rule carries a rationale in its SQL header: yes"))
every active rule carries a rationale in its SQL header: yes

Where results appear

  • ctd-qaqc — runs this registry interactively against a release, one rule or one cruise at a time, and records review verdicts. It also runs the registry against an ad-hoc .btl upload, so a cast can be checked before it is anywhere near a release.
  • ingest_calcofi_ctd-cast.qmd — runs the same registry over the parquet each ingest writes, so the notebook reports the condition of the data it just published.

Both call calcofi4db::qc_run_all(). There is one engine and one registry: a number in the app and a number in the notebook cannot disagree.

How to comment on this document

It renders to two formats from one source: HTML and .docx. The Word version exists so the CalCOFI CTD team can suggest edits in track-changes; it is a rendering, not a source, so edits come back as comments to be applied to metadata/qc_rules/ rather than merged into the document.

Where the change belongs depends on what it is:

what you want to change where it actually lives
a threshold, or the reasoning behind one the header comment of that rule’s metadata/qc_rules/sql/*.sql — it is harvested into this document verbatim
whether a rule runs at all active in metadata/qc_rules/rules.csv
the value or depth range a type is valid over metadata/measurement_type.csv
what a quality code means metadata/measurement_qual.csv
something only the data providers can settle metadata/calcofi/ctd-cast/questions.csv — cite the Q label

Anything else — the prose, the diagram, what is deliberately not checked — is in ctd-cast_qa-qc-protocol.qmd itself.

Session Info

Code
sessioninfo::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
   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)
   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)
   brio            1.1.5     2024-04-24 [1] CRAN (R 4.5.0)
   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)
 P calcofi4db    * 3.15.0    2026-08-13 [?] load_all()
   cli             3.6.6     2026-04-09 [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)
   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)
   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)
   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)
   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)
   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)
   here          * 1.0.2     2025-09-15 [1] CRAN (R 4.5.0)
   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)
   igraph          2.3.2     2026-05-29 [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)
   knitr         * 1.51      2025-12-20 [1] CRAN (R 4.5.2)
   later           1.4.8     2026-03-05 [1] CRAN (R 4.5.2)
   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)
   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)
   memoise         2.0.1     2021-11-26 [1] CRAN (R 4.5.0)
   mime            0.13      2025-03-17 [1] CRAN (R 4.5.0)
   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)
   promises        1.5.0     2025-11-01 [1] CRAN (R 4.5.0)
   purrr         * 1.2.2     2026-04-10 [1] CRAN (R 4.5.2)
   R6              2.6.1     2025-02-15 [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)
   rmarkdown       2.31      2026-03-26 [1] CRAN (R 4.5.2)
   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)
   sass            0.4.10    2025-04-11 [1] CRAN (R 4.5.0)
   sessioninfo     1.2.3     2025-02-05 [1] CRAN (R 4.5.0)
   shiny           1.14.0    2026-06-21 [1] CRAN (R 4.5.2)
   snakecase       0.11.1    2023-08-27 [1] CRAN (R 4.5.0)
   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)
   testthat      * 3.3.2     2026-01-11 [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)
   tzdb            0.5.0     2025-03-15 [1] CRAN (R 4.5.0)
   usethis         3.2.1     2025-09-06 [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)
   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)
   withr           3.0.3     2026-06-19 [1] CRAN (R 4.5.2)
   xfun            0.59      2026-06-19 [1] CRAN (R 4.5.2)
   xtable          1.8-8     2026-02-22 [1] CRAN (R 4.5.2)
   yaml            2.3.12    2025-12-10 [1] CRAN (R 4.5.2)

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

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

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