Clean a CTD cruise × variable

propose flags in PostgreSQL, curators review, clean 1 m bins fall out — originals untouched

Published

2026-08-20

1 What this is

A parameterized QA/QC notebook for one cruise × one variable, written in Python for the CTD team to copy and tweak. It runs against the multi-user PostgreSQL database (calcofi, schema ctd) where the entire cast-file archive lives verbatim and immutable — every one of the 82 source columns, every -99, every superseded preliminary file. Cleaning here never edits a value: you propose flags into the ctd.flag ledger, curators (Rasmus, Ben G, Kelsey, Erin) accept or reject them, and derived products like the 1 m bins are recomputed from originals + accepted flags — so any product can be regenerated, and any decision can be revisited.

Figure 1 is the whole loop: raw files land once in immutable tables; rules propose, humans decide, and every output is derived — each arrow is a section below with the code that runs it.

flowchart TB
  A["calcofi.org archive<br/>409 files, 1993 → now"]
  A -->|"load once, verbatim"| B

  subgraph PG["PostgreSQL — schema ctd"]
    B[("ORIGINALS<br/>10.8M scans<br/>immutable")]
    F[("the LEDGER<br/>ctd.flag")]
    V["clean QC views"]
  end

  B -->|"QC rules (§4)"| P["flag candidates (§5)"]
  P -->|"propose (§6)"| F
  F -->|"curators review (§6)"| V
  B --> V
  V -->|"1 m bins (§7)"| W[("work.clean_1m_*")]
  F -->|"nightly export"| G["flag_accepted.parquet<br/>(public GCS)"]
  G -.->|"measurement_qual"| R["public DB release"]

  style PG fill:#f4f8fc,stroke:#2A4F7C,color:#193E6D
  classDef orig fill:#193E6D,color:#fff,stroke:#2A4F7C
  classDef ledger fill:#F4D530,color:#193E6D,stroke:#c9ad13
  classDef derived fill:#dbe7f3,color:#193E6D,stroke:#2A4F7C
  classDef ext fill:#f4f4f4,color:#333,stroke:#bbb
  class B orig
  class F ledger
  class V,W derived
  class A,P,G,R ext
Figure 1: The QA/QC loop. Originals (navy — ctd.file / ctd.scan / ctd.scan_issue, all 82 source columns, immutable by trigger) are loaded once from the calcofi.org cast-file archive by load_pg_ctd.qmd. Automated QC rules (§4: cc_qc_spike, cc_qc_sensor_pair, cc_qc_range) produce evidence and triage tables (§5), which cc_propose_flags() writes idempotently into the ledger (yellow — ctd.flag, every change audited). Curators accept or reject (§6, one UPDATE in pgAdmin or SQL); the clean views (ctd.v_scan_qc / ctd.v_scan_clean) apply only accepted verdicts, so every product — like the 1 m bins cc_bin_1m() writes to work.clean_1m_* (§7) — can be regenerated at any time. Each night the accepted flags are published to gs://calcofi-db/qc/ctd/flag_accepted.parquet, where the public database release applies them as measurement_qual.

The QC checks are the same SQL rules the ctd-qaqc app runs (metadata/qc_rules/), ported into documented calcofi4py helpers — SQL is SQL, so they run identically from Python, R, or psql.

To run it yourself you need an account (Server Access walks through the SSH key, tunnel and ~/.pgpass), then:

pip install "calcofi4py[viz] @ git+https://github.com/CalCOFI/calcofi4py" jupyter
quarto render clean_ctd_cruise-var.qmd -P study:2607SH -P variable:tempave

Rendering as a cleaning archive. The rendered HTML is a complete, self-contained record of one cleaning pass — parameters, evidence, what was proposed, and (in the Reproducibility accordion at the end) the exact software and rule versions that produced it. Name the output per cruise × variable and keep it:

quarto render clean_ctd_cruise-var.qmd \
  -P study:2607SH -P variable:tempave \
  --output clean_ctd_2607SH_tempave.html      # lands in _output/ next to the default name

Parameters (the cell below; override with -P):

study    = "2607SH"     # source cruise id — the most recent, Jul 2026, R/V Bell M. Shimada
variable = "tempave"    # a ctd.scan measurement column (see ctd.scan_column)
units    = "degC"
sensor_pair = ("temp1", "temp2")   # the redundant pair behind the averaged variable

2 Connect

cc_pg_connect(tunnel=True) opens ssh -N calcofi for you and authenticates from ~/.pgpass — no password appears in this notebook, ever.

import pandas as pd
import calcofi4py as cc

con = cc.cc_pg_connect(tunnel=True)
pg_version = con.execute("SHOW server_version").fetchone()[0]
pd.options.display.max_rows = 12
SSH tunnel up: localhost:15432 -> calcofi:5432

3 The cruise at a glance

Every station occupation is a numbered cast_seq (extracted from cast_id: "2607_001d" → 1) with a down- and an upcast at the same position — that number is the key to cross-reference between the map (Figure 2), the profiles (Figure 5), the section (Figure 3) and the flag triage (Figure 4).

casts = cc.cc_ctd_casts(con, study)
occ = casts.groupby("cast_seq").size()
print(f"{study} (cruise_key {casts.cruise_key.iloc[0]}): "
      f"{casts.cast_seq.nunique()} station occupations (cast_seq "
      f"{int(casts.cast_seq.min())}-{int(casts.cast_seq.max())}), "
      f"{len(casts)} casts ({(casts.cast_dir == 'D').sum()} down / {(casts.cast_dir == 'U').sum()} up; "
      f"{(occ == 2).sum()} complete down+up pairs), "
      f"{casts.datetime_utc.min():%Y-%m-%d} to {casts.datetime_utc.max():%Y-%m-%d}, "
      f"max depth {casts.depth_max.max():.0f} m, "
      f"{casts.n_scans.sum():,} scans total")
casts.head(4)
2607SH (cruise_key 2026-07-3322): 72 station occupations (cast_seq 1-72), 144 casts (72 down / 72 up; 72 complete down+up pairs), 2026-06-30 to 2026-07-13, max depth 747 m, 56,993 scans total
cast_seq file_id cast_id cast_dir ord_occ sta_id line sta datetime_utc lat lon n_scans depth_min depth_max cruise_key data_stage is_best_stage
0 1 559 2607_001d D 001 093.3 028.0 93.3 28.0 2026-06-30 01:23:31 32.909441 -117.397487 517 2.0 518.0 2026-07-3322 preliminary_without_bottle True
1 1 560 2607_001u U 001 093.3 028.0 93.3 28.0 2026-06-30 01:35:26 32.907397 -117.400851 517 2.0 518.0 2026-07-3322 preliminary_without_bottle True
2 2 559 2607_002d D 002 093.4 026.4 93.4 26.4 2026-06-30 04:03:02 32.946628 -117.283882 20 2.0 21.0 2026-07-3322 preliminary_without_bottle True
3 2 560 2607_002u U 002 093.4 026.4 93.4 26.4 2026-06-30 04:04:46 32.946617 -117.283746 21 1.0 21.0 2026-07-3322 preliminary_without_bottle True
cc.cc_station_map(casts, title=f"{study} — station occupations by cast_seq")
Figure 2: Station occupations, one labeled marker per cast_seq (the down/up pair collapses to one point; hover for station, time, both directions’ scan counts). Occupations whose source files carry -99 positions are absent here but present in every table.
scans = cc.cc_ctd_scans(con, study, columns=[variable, *sensor_pair])
print(f"{len(scans):,} scans; accepted-QC columns come along: "
      f"{[c for c in scans.columns if c.endswith(('_qc', '_fix'))]}")
56,993 scans; accepted-QC columns come along: ['tempave_qc', 'tempave_fix', 'temp1_qc', 'temp1_fix', 'temp2_qc', 'temp2_fix']

The source files use -99 as a missing-position sentinel, kept verbatim in ctd.scan (only the derived ctd.cast positions NULL it). Table 1 is the accounting: any occupation missing from Figure 2 appears here with how many of its scans carry the sentinel.

Table 1: Occupations whose source files carry -99 positions (scan-level counts; on_map = whether a usable position remains for Figure 2).
pos = (scans.assign(no_pos=(scans.lat_dec == -99) | (scans.lon_dec == -99))
       .groupby("cast_seq")
       .agg(n_scans=("scan_id", "count"), n_scans_neg99=("no_pos", "sum")))
mapped = casts.dropna(subset=["lat", "lon"]).cast_seq.unique()
nopos = (pos[pos.n_scans_neg99 > 0]
         .assign(pct=lambda d: (100 * d.n_scans_neg99 / d.n_scans).round(1),
                 on_map=lambda d: d.index.isin(mapped))
         .reset_index())
if len(nopos):
    display(nopos)
else:
    print(f"none — all {pos.index.nunique()} occupations carry real positions on every scan")
none — all 72 occupations carry real positions on every scan
cc.cc_section_plot(scans, casts, variable, units=units,
                   title=f"{study}{variable} section")
Figure 3: Quick-look section: cast_seq × depth, colored by value (each vertical stripe is one downcast’s scans — not an interpolated product). The x axis is the same cast_seq as Figure 2.

4 Run the QC checks

Three portable rules, straight from the registry. Each returns a DataFrame of suspect scans — evidence first, flags second.

Spikes — a scan that leaps away from the midpoint of its neighbours while the neighbours agree with each other. That second clause is the whole trick: without it, every steep-but-smooth thermocline gradient fires (on the reference cruise: 92 naive hits, 19 with neighbour agreement — the other 73 were real gradients).

spikes = cc.cc_qc_spike(con, study, variable, spike_threshold=0.5, neighbour_tol=0.5)
print(f"{len(spikes)} spike candidates on {spikes.cast_id.nunique()} casts "
      f"({(spikes.cast_dir == 'U').mean():.0%} on upcasts)")
spikes.sort_values("excursion", ascending=False).head(6)
414 spike candidates on 9 casts (59% on upcasts)
scan_id cast_id cast_dir depth value value_above value_below excursion neighbour_gap
25 7645702 2607_005d D 219.0 7.8735 10.7156 10.6163 2.7925 0.0993
204 7673968 2607_005u U 516.0 0.4526 -1.9045 -2.3695 2.5896 0.4650
125 7674320 2607_005u U 164.0 2.2746 0.2813 0.2593 2.0043 0.0220
103 7674413 2607_005u U 71.0 -0.2618 1.8092 1.6752 2.0040 0.1340
134 7674280 2607_005u U 204.0 0.4755 2.3674 2.4872 1.9518 0.1198
205 7646005 2607_006d D 7.0 -0.6468 1.3091 1.2792 1.9410 0.0299

Sensor pair — the two temperature sensors disagreeing beyond calibration tolerance. The source’s own *Q codes 1/2 (“use primary”/“use secondary”) exist precisely because one of a pair misbehaves; a persistent gap is how that is caught early.

pair = cc.cc_qc_sensor_pair(con, study, *sensor_pair, threshold=0.05)
print(f"{len(pair)} scans with |{sensor_pair[0]} - {sensor_pair[1]}| > 0.05 {units}")
pair.head(4)
3942 scans with |temp1 - temp2| > 0.05 degC
scan_id cast_id cast_dir depth v1 v2 gap
0 7645707 2607_005d D 224.0 10.1237 -31.9483 42.0720
1 7645708 2607_005d D 225.0 10.1207 -31.8441 41.9648
2 7646005 2607_006d D 7.0 20.1445 -21.4382 41.5827
3 7646007 2607_006d D 9.0 20.1150 -20.9034 41.0184

Declared bounds — the impossible, not the unusual (an unconverted sentinel, a scaling error). Bounds are generous on purpose.

oob = cc.cc_qc_range(con, study, variable, valid_min=-3, valid_max=40)
print(f"{len(oob)} values outside [-3, 40] {units}")
197 values outside [-3, 40] degC

5 Triage: which casts most need a human

Flags rolled up per cast_seq — depth span, value range, share of the cast’s scans. Casts absent from the table have no flags.

candidates = pd.concat([
    spikes[["scan_id", "depth"]].assign(rule_key="ctd_spike_v1"),
    oob[["scan_id", "depth"]].assign(rule_key="ctd_value_out_of_range_v1"),
]).assign(flag_id=lambda d: range(len(d)))
triage = cc.cc_flag_summary(candidates, scans, variable)
worst = int(triage.cast_seq.iloc[0])
triage
Table 2: Proposed + accepted flags per cast_seq (sorted by count) — the inspection worklist.
cast_seq n_flags depth_min depth_max value_min value_max n_ctd_spike_v1 n_ctd_value_out_of_range_v1 pct_scans_flagged
0 6 325 7.0 514.0 -5.0872 11.8330 206 119 31.46
1 5 258 11.0 516.0 -10.9123 10.2011 180 78 25.05
2 1 23 29.0 226.0 9.0743 17.0211 23 0 2.22
3 4 2 10.0 11.0 18.3892 18.8822 2 0 5.13
4 20 1 62.0 62.0 16.0585 16.0585 1 0 0.10
5 59 1 17.0 17.0 13.5915 13.5915 1 0 2.50
6 70 1 9.0 9.0 14.3537 14.3537 1 0 0.10
import plotly.express as px
bar = (candidates.merge(scans[["scan_id", "cast_seq"]], on="scan_id")
       .groupby(["cast_seq", "rule_key"]).size().rename("n").reset_index())
fig = px.bar(bar, x="cast_seq", y="n", color="rule_key", height=340,
             color_discrete_map={"ctd_spike_v1": "#2A4F7C",
                                 "ctd_value_out_of_range_v1": "#F4D530"},
             labels={"cast_seq": "cast_seq", "n": "flag candidates"},
             title=f"{study}{variable} flag candidates by cast")
fig.update_xaxes(dtick=5)
fig
Figure 4: The same triage visually: flag candidates per cast_seq, stacked by rule. A tall bar is a cast to open in Figure 5; compare against its neighbours in Figure 3.

Cast 6 dominates — open it (Figure 5 selects it by default; use the dropdown for any other cast_seq). Up- and downcast of the same occupation should roughly agree: a one-sided excursion is instrument, not ocean.

cc.cc_profile_explorer(scans, variable, flags=candidates, units=units, default=worst,
                       title=f"{study}{variable} profiles")
Figure 5: Depth profiles with a dropdown per cast_seq (default: the worst cast from Table 2; ‘all casts’ shows the cruise envelope). Down = blue, up = orange, flag candidates = violet ×.

6 Propose flags into the ledger

cc_propose_flags() writes one ctd.flag row per scan × variable with IODE code 4 (bad), your username, a reason, and the rule that found it. It is idempotent — a scan already carrying a proposed or accepted flag for this variable is skipped, so re-running this notebook never stacks duplicates. Nothing you propose changes any data: that takes a curator’s accepted.

n_sp = cc.cc_propose_flags(
    con, spikes.scan_id, variable, qual_code=4,
    reason=f"single-scan spike >0.5 {units}, neighbours agree within 0.5",
    rule_key="ctd_spike_v1")
n_ob = cc.cc_propose_flags(
    con, oob.scan_id, variable, qual_code=9,
    reason="outside declared physical bounds [-3, 40] — sentinel or conversion error",
    rule_key="ctd_value_out_of_range_v1")
print(f"proposed: {n_sp} spike + {n_ob} out-of-bounds flags "
      f"(0s mean an earlier run already proposed them)")
proposed: 0 spike + 0 out-of-bounds flags (0s mean an earlier run already proposed them)

The ledger as it now stands for this cruise — every row carries proposer, timestamp, and, once reviewed, the reviewer and verdict (full history in ctd.flag_audit):

ledger = cc.cc_flags(con, study=study)
print(ledger.status.value_counts().to_dict())
ledger.tail(6)
{'proposed': 574}
flag_id scan_id study cast_id depth variable qual_code qual_label proposed_value rule_key reason status created_by created_at reviewed_by review_note
568 987 7646180 2607SH 2607_006d 182.0 tempave 9 missing None ctd_value_out_of_range_v1 outside declared physical bounds [-3, 40] — se... proposed bebest 2026-08-20 05:36:15.539224+00:00 None None
569 988 7646181 2607SH 2607_006d 183.0 tempave 9 missing None ctd_value_out_of_range_v1 outside declared physical bounds [-3, 40] — se... proposed bebest 2026-08-20 05:36:15.539224+00:00 None None
570 989 7646187 2607SH 2607_006d 189.0 tempave 9 missing None ctd_value_out_of_range_v1 outside declared physical bounds [-3, 40] — se... proposed bebest 2026-08-20 05:36:15.539224+00:00 None None
571 990 7646198 2607SH 2607_006d 200.0 tempave 9 missing None ctd_value_out_of_range_v1 outside declared physical bounds [-3, 40] — se... proposed bebest 2026-08-20 05:36:15.539224+00:00 None None
572 991 7646199 2607SH 2607_006d 201.0 tempave 9 missing None ctd_value_out_of_range_v1 outside declared physical bounds [-3, 40] — se... proposed bebest 2026-08-20 05:36:15.539224+00:00 None None
573 992 7646237 2607SH 2607_006d 239.0 tempave 9 missing None ctd_value_out_of_range_v1 outside declared physical bounds [-3, 40] — se... proposed bebest 2026-08-20 05:36:15.539224+00:00 None None

Curators: accepting is one UPDATE, from psql, pgAdmin, R or here —

# curators only (calcofi_curator role); everyone else gets a clean permission error
con.execute("""
  UPDATE ctd.flag SET status = 'accepted', review_note = 'agree — heave spikes'
  WHERE flag_id = ANY(%s)""", ([1234, 1235],))
con.commit()

7 Derive the clean 1 m bins

ctd.v_scan_clean is the originals with accepted fixes substituted and accepted-bad values NULLed — the ledger’s verdicts, nothing else. Binning it gives the product; anyone can regenerate it at any time, which is the point of never editing originals. write_table= also publishes it as a real table in the shared work schema, so colleagues (R, Python, pgAdmin) can query it by name.

tbl = f"clean_1m_{study.lower()}_{variable}"
bins = cc.cc_bin_1m(con, study, variable, cast_dir="D", write_table=tbl)
print(f"{len(bins):,} (cast × 1 m depth) bins -> work.{tbl}")
bins.head(4)
28,479 (cast × 1 m depth) bins -> work.clean_1m_2607sh_tempave
cast_id cast_dir depth_m value n sd
0 2607_001d D 2 20.6867 1 None
1 2607_001d D 3 20.6921 1 None
2 2607_001d D 4 20.6913 1 None
3 2607_001d D 5 20.6798 1 None
import plotly.graph_objects as go

w = scans.query("cast_seq == @worst and cast_dir == 'D'").cast_id.iloc[0]
raw = scans.query("cast_id == @w").dropna(subset=[variable])
bw  = bins.query("cast_id == @w")
fig = go.Figure()
fig.add_scatter(x=raw[variable], y=raw.depth, mode="markers", name="raw scans",
                marker=dict(size=3, color="lightgrey"))
fig.add_scatter(x=bw.value, y=bw.depth_m, mode="lines", name="clean 1 m bins",
                line=dict(color="#1f77b4", width=2))
fig.update_layout(height=600, title=f"{study} cast {worst} — raw vs clean 1 m {variable}",
                  xaxis_title=f"{variable} ({units})", yaxis_title="depth (m)")
fig.update_yaxes(autorange="reversed")
fig
Figure 6: Raw downcast scans (grey) vs the clean 1 m binned product (blue) for the worst cast of Table 2. Until curators accept flags the two coincide; each acceptance moves only the blue line — the grey is immutable.
con.close()
cc.cc_pg_tunnel_close()

8 Reproducibility

If this rendered page is kept as the archive of a cleaning pass, the block below is its provenance: the software (above all calcofi4py), the git commit of the QC rule registry this repo carries (metadata/qc_rules/ — with a loud note if the working tree had uncommitted rule changes), and the database server it ran against.

print(cc.cc_session_info(
    repos={
        "workflows": (".", None),                     # this repo (the notebook + registries)
        "qc_rules":  (".", "metadata/qc_rules"),      # the QC rule definitions specifically
    },
    extra={
        "postgresql": pg_version,
        "database":   "calcofi @ ssh.calcofi.io (schema ctd)",
        "release":    cc.cc_resolve_version("latest"),
    }))
rendered_utc    2026-08-20 09:17:32 UTC
python          3.14.5 (CPython)
platform        macOS-15.7.1-arm64-arm-64bit-Mach-O
executable      /Users/bbest/Github/CalCOFI/calcofi4py/.venv/bin/python

packages
  calcofi4py    0.3.4
  duckdb        1.5.5
  psycopg       3.3.4
  pandas        3.0.5
  plotly        6.9.0
  numpy         2.5.2
  jupyter_core  5.9.1
  ipykernel     7.3.0

data-rule / repo versions (last commit touching the path)
  workflows     28e49e1 2026-08-20 +uncommitted-changes
  qc_rules      d68738f 2026-08-02

environment
  postgresql    18.6 (Debian 18.6-1.pgdg13+2)
  database      calcofi @ ssh.calcofi.io (schema ctd)
  release       v2026.08.14

9 Where to go from here

  • Change the parameters: any cruise in ctd.file (142 studies, 1993→present), any measurement column in ctd.scan_column — salinity (salt1; saltave_corr is empty in sensor-only preliminary tiers like this one), oxygen (ox1), fluorescence (fluorv), …
  • Tune the thresholds — the rule registry’s values are starting points; Q25 in questions.csv asks the team to confirm the flag vocabulary and the curator list.
  • Review: proposed flags wait in ctd.flag (SELECT * FROM ctd.flag WHERE status = 'proposed', or the ledger table above) — accept, reject, or propose better values (qual_code = 5 + proposed_value).
  • Everything downstream is automatic: accepted flags flow into ctd.v_scan_qc / ctd.v_scan_clean immediately, and each night the ledger is published to gs://calcofi-db/qc/ctd/ so the public release ingest can apply it — no live database dependency.
  • API docs for every helper used here: calcofi.io/calcofi4py.