calcofi4py
¶
CalCOFI Python helpers — the thin sibling of calcofi4r. Two data stores, a few verbs, no credentials in code.
— the version these examples were run against;
calcofi4py.__version__ tells you yours.
pip install "calcofi4py[viz] @ git+https://github.com/CalCOFI/calcofi4py"
[viz] adds pandas + plotly, which every example below uses (the CTD helpers return
DataFrames; the maps and profiles are plotly). Without it you get release access only
(DuckDB). The extras go inside the quotes with the git URL — a bare
pip install 'calcofi4py[viz]' only works after the package is already installed.
Updating and pinning. The package is not on PyPI, so pip install 'calcofi4py[viz]'
on an existing install just reports Requirement already satisfied and changes nothing.
Re-run the git URL — with --upgrade — to move to the latest main, or pin a release tag:
pip install --upgrade "calcofi4py[viz] @ git+https://github.com/CalCOFI/calcofi4py" # latest main
pip install --upgrade "calcofi4py[viz] @ git+https://github.com/CalCOFI/calcofi4py@v0.3.6" # a release tag
python -c "import calcofi4py as cc; print(cc.__version__)" # confirm
In RStudio's Python console (rstudio.calcofi.io) the interpreter is embedded once per R
session: after an upgrade do Session → Restart R, or import keeps returning the old module.
The public database releases (no account needed)¶
Immutable, versioned Parquet on a public bucket; DuckDB reads only what a query touches, straight over HTTPS.
import calcofi4py as cc
cc.__version__ # '0.9.2' — confirm before copying the examples below
con = cc.cc_get_db() # latest release, every table as a view
con.sql("SHOW TABLES")
df = con.sql("""
SELECT date_trunc('year', s.datetime) AS year, count(*) AS casts -- sample.datetime is UTC
FROM sample s WHERE s.dataset_key = 'calcofi_ctd-cast'
GROUP BY 1 ORDER BY 1
""").df()
cc.cc_query("SELECT count(*) FROM obs").fetchone() # one-shot
cc.cc_get_db("v2026.08.14") # pin a version (reproducible)
cc.cc_get_db(supplemental=True) # + obs_ctd_full (216M rows), obs_mets_full
cc.cc_list_versions()[:3]
# quality flags: obs.measurement_qual is each dataset's OWN code set (bottle/CTD 8 = suspect,
# 9 = missing/bad; DIC WOCE 3/4/9). A flagged value is still a row — filter it:
con.sql(f"SELECT * FROM obs o WHERE o.measurement_type = 'oxygen_ml_l' AND {cc.qual_ok_sql('o')}")
To see what's in each table first: the Schema explorer. More: Data Access.
The CTD team's PostgreSQL working database (account required)¶
Private, multi-user, reached over SSH — see
Server Access for the account,
the ~/.ssh/config alias calcofi, and the ~/.pgpass file (your password
lives there and in no script, ever).
import calcofi4py as cc
cc.__version__ # '0.9.2' — cc_withdraw_flags() needs >= 0.3.5
con = cc.cc_pg_connect(tunnel=True) # opens `ssh -N calcofi` for you; ~/.pgpass auth
con.execute("SELECT count(*) FROM ctd.cast WHERE is_best_stage").fetchone()
casts = cc.cc_ctd_casts(con, "2304SH") # one row per cast (best stage)
scans = cc.cc_ctd_scans(con, "2304SH", cast_id="2304_001d",
columns=["temp1", "temp2"]) # this cast: 40 scans, 3–42 m, 1 m bins
# propose a QC flag — look first, write second, and you can still undo
where, args = "study = %s AND cast_id = %s AND depth = %s", ("2304SH", "2304_001d", 20)
hit = con.execute(f"SELECT scan_id, depth, temp1, temp2 FROM ctd.v_scan_best WHERE {where}", args).fetchall()
assert len(hit) == 1, hit # [] = your WHERE matches nothing, and the INSERT below would flag nothing, silently
(flag_id,) = con.execute(f"""
INSERT INTO ctd.flag (scan_id, variable, qual_code, reason)
SELECT scan_id, 'temp1', 3, 'README example (withdrawn right after)'
FROM ctd.v_scan_best WHERE {where}
RETURNING flag_id
""", args).fetchone()
con.commit() # con.rollback() instead discards it before anyone sees it
cc.cc_flags(con, "2304SH", status="proposed") # the ledger: who proposed what, and its fate
# undo: the ledger is append-only, so undo = withdraw your own proposal (the audit trail keeps it);
# curators accept/reject everything else in pgAdmin or SQL
cc.cc_withdraw_flags(con, [flag_id], note="README example")
cc.cc_pg_tunnel_close()
Both at once — release Parquet ⋈ PostgreSQL in one DuckDB query¶
The two stores answer different questions. The public release is what everyone
sees: immutable, versioned Parquet with every CalCOFI dataset integrated on shared
keys. The PostgreSQL database is the CTD team's working state: the raw cast
archive and the flag ledger, changing daily as people propose and review. They share
cruise_key (YYYY-MM-NODC, e.g. 2026-07-3322), so a question that spans them —
which cruises are we still flagging, and what does the public release already
publish for those cruises? — is one join away.
DuckDB is the bridge. cc_get_db() registers the release tables as views over the
Parquet (called by their bare names: sample, cruise, …); cc_pg_attach() then
ATTACHes PostgreSQL as a second catalog named pg (DuckDB's postgres extension
talks libpq through your SSH tunnel and reads ~/.pgpass; read-only by default), so
its tables are pg.<schema>.<table>. From there it is ordinary SQL — DuckDB plans
across both sources and hands back one DataFrame.
import calcofi4py as cc
cc.cc_pg_tunnel() # SSH tunnel up (cc_pg_connect(tunnel=True) does the same)
con = cc.cc_get_db(tables=["sample", "cruise"]) # DuckDB with the release tables as views
cc.cc_pg_attach(con) # + PostgreSQL attached as catalog `pg` (pg.ctd.*, pg.work.*)
con.sql("SHOW ALL TABLES") # both catalogs, side by side
qc_vs_release = con.sql("""
WITH qc AS ( -- PostgreSQL side: the team's working state
SELECT fi.cruise_key, fi.study,
count(*) FILTER (WHERE f.status = 'proposed') AS flags_proposed,
count(*) FILTER (WHERE f.status = 'accepted') AS flags_accepted
FROM pg.ctd.flag f
JOIN pg.ctd.file fi USING (file_id) -- a flag sits on a scan/file; the file knows its cruise
GROUP BY 1, 2)
SELECT qc.study, qc.cruise_key, qc.flags_proposed, qc.flags_accepted,
count(s.sample_key) AS casts_in_release -- release side: what the public sees today
FROM qc
LEFT JOIN sample s -- `sample` = the release view (no prefix)
ON s.cruise_key = qc.cruise_key AND s.dataset_key = 'calcofi_ctd-cast'
GROUP BY ALL
ORDER BY qc.cruise_key DESC
""").df()
# study cruise_key flags_proposed flags_accepted casts_in_release
# 2607SH 2026-07-3322 574 0 122 <- being cleaned; already public
# 2304SH 2023-04-3322 0 0 226 <- its 3 flags were withdrawn
cc.cc_pg_tunnel_close()
Writes go the other way too: cc_pg_attach(con, read_only=False) lets a
CREATE TABLE pg.work.my_subset AS SELECT … FROM sample WHERE … land release rows in
your PostgreSQL schema for the team to work on.
Walkthrough¶
CTD QA/QC, end to end — the
helpers working together on one cruise × one variable, read-only: casts → map → QC rules
→ triage → ledger → clean 1 m bins → a cross-store query, ending with the session info
that makes the page a reproducible record. Pre-rendered by someone with an account
(scripts/render_articles.sh); the site build holds no credentials.
Conventions inherited from calcofi4r (do not drift)¶
- Secrets only in
~/.pgpass/%APPDATA%\postgresql\pgpass.conf; never a password argument in examples. PGHOST/PGPORT/PGUSERoverride every default; on the CalCOFI server the host defaults topostgis(no tunnel), elsewherelocalhost.- Release access follows
catalog.jsonexactly:partitionedtables are s3-glob views withhive_partitioning,supplementaltables opt-in.
Dev¶
pip install -e ".[dev]"
pytest -q # pure-logic + (if online) release tests, including this README
CALCOFI_PG_TEST=1 pytest -q # + live PostgreSQL tests (tunnel + ~/.pgpass)
The examples above are tests. tests/test_readme.py runs every python block of this
README: the release blocks against the promoted release on every push (.github/workflows/test.yml),
the PostgreSQL blocks with CALCOFI_PG_TEST=1. The CalCOFI database release pipeline
(CalCOFI/workflows test_release.qmd) runs the same test against each new release
before promoting it, pointed there by CALCOFI_RELEASE_VERSION (and
CALCOFI_RELEASE_PREFIX on a staging run), so a column the release renames fails the
release, not the reader. A block that must not run under a test starts with
# readme: skip — none does today.