space::cdf
cheatah-space v0.1.0-alpha — Biome Standard 0.6.5-alpha
NASA Common Data Format I/O for cheatah — the format space-physics data ships in (variables, attributes, records, 0–10-D arrays, optional compression). Written from scratch in C++ with zero dependencies: NASA's CDF library is not linked and not required to build, install, or use space.cdf. Goal: the fastest CDF reader/writer we can build.
Status: working — reads CDF 3.x, including GZIP-compressed variables, verified against real mission files. Writing is not implemented.
Reference: https://cdf.gsfc.nasa.gov/, and the authoritative CDF 3.9 Internal Format Description. We implement from that specification; we do not read or copy NASA's source.
NASA's CDF distribution is not public domain — per CDF_copyright.txt it "may
be copied or redistributed as long as it is not sold for profit", may be incorporated into other products, and carries modification-notice requirements. So we use it only as an optional, dev-only oracle — to verify our output byte-for-byte and to benchmark against — fetched on demand by scripts/cdf-oracle.sh into space/cdf/vendor/ (git-ignored), never committed, never shipped, and gated behind a flag. scripts/check_no_vendored_nasa.sh enforces that on every push. A normal biome add cheatah-space never touches any of it.
What works today
space.cdf opens a CDF 3.x file, lists its variables, and hands their records back as cheatah ndarrays — including GZIP-compressed variables, which is nearly everything in NASA's archive.
import io
import ndarray
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(cdf.var_names(f)) # 47 variables
io.print(cdf.data_type_name(f, "F")) # CDF_REAL4
io.print(cdf.record_count(f, "F")) # 44640 — a month of one-minute samples
let b = cdf.values(f, "F") # ndarray[float64], IMF magnitude in nT
io.print(b[0], b[1], b[2]) # 6.92 5.84 5.71function | what it gives you |
|---|---|
| maps the file, parses every descriptor |
| every variable, rVariables first |
| how many records the variable has |
|
|
| records, then each varying dimension |
|
|
|
|
values is lossless for every type a double can hold exactly, which is all of them except CDF_INT8 and CDF_TIME_TT2000; asking for those is an error naming values_i64 rather than a silent rounding. values_i64 refuses the float types for the same reason.
Plotting
The arrays go straight into cheatah-plot. examples/purr_space/03_plot_omni_imf.purr is the worked version — open a CDF, decode Epoch and F, drop the fill values, and render a PNG:
scripts/cdf-corpus.sh fetch --tier 1
examples/purr_space/run_examples.sh # writes examples/purr_space/out/*.pngTwo things that example exists to show. cheatah-plot has no time axis — its ticks format as %.6g — so a raw CDF_EPOCH of 6.3e13 ms renders as one repeated label; rebase onto hours and name the epoch in the axis label. And OMNI marks a missing sample as ~1e31, which would flatten every real variation into a line at the bottom of the frame, so the fill values are dropped rather than plotted.
What is deferred
The reader refuses what it does not yet handle, by name, rather than guessing:
refused | error |
|---|---|
CDF 2.x and earlier |
|
whole-file (CCR) compression |
|
multi-file CDFs ( |
|
VAX F/D/G float encodings |
|
sparse records with real gaps |
|
RLE / Huffman / adaptive Huffman |
|
N-D variables in column-major files |
|
Also still to come: the attributes API, the writer, the MD5 checksum, cryptographic signing over a canonical content digest, and the byte-for-byte differential harness against NASA's own library. RLE, Huffman and adaptive Huffman are last on purpose — the format specification names those algorithms but never documents their bitstreams, and no file in the public archive uses any of them, so they have to be reverse-engineered from files generated on demand.
Plan
Phase 0 — bridge (done in space.time). unix_to_cdf_epoch / cdf_epoch_to_unix convert the CDF_EPOCH (ms since year 0). TT2000 (ns since J2000 + leap seconds) and EPOCH16 (picoseconds) land here with their leap-second table.
Phase 1 — native reader. Parse the single-file CDF layout directly: the descriptor records (CDR/GDR), variable descriptors (zVDR/rVDR), variable index/value records (VXR/VVR), and attribute records (ADR/AEDR). Decompress (RLE, Huffman, adaptive Huffman, GZIP — own implementations, no zlib dependency) only the records actually touched. Decode straight into cheatah ndarray; memory-map + zero-copy slice where the layout allows.
Phase 2 — native writer + MD5 checksum + compression.
Verification & benchmark (optional). A dev harness diffs our reader against the NASA C lib over a fixed corpus (scalar-record, large multi-dim, compressed) and records ns/record + GB/s. Each function's measured speedup vs the reference becomes its @perf tag; we ship only when we win. The harness is skipped automatically when the reference isn't present.
API sketch (subject to change)
import space.cdf
let f = cdf.open("mms1_fgm.cdf") # parse descriptors lazily
let t = f.var("Epoch").records(0, 1000) # ndarray, decoded onceHouse rules
Every function documents @complexity, @alloc, truthful @systest tags, and — because a reference exists — @perf stating the speedup over NASA's C lib.
Classes
CdfError— The exception every malformed-file path raises.File— An opened CDF.LeapSecondEntry— One row of NASA'sCDFLeapSeconds.txt, held as exact scaled integers.
Functions
The number of entries in the leap-second table.
42 as shipped; grows only when a leap second is added upstream.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
io.print(cdf.leap_second_count()) # 42How many leading entries lie in the pre-1972 "rubber second" era, where TAI-UTC drifts continuously with the day rather than stepping by whole seconds.
14, matching NERA1 in NASA's own implementation.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
io.print(cdf.leap_second_drift_rows()) # 14 — entries 0..13 driftOne entry of the leap-second table, by index.
index | 0-based; anything outside [0, leap_second_count()) yields a zeroed entry. |
the entry, by value.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
# The most recent entry: TAI-UTC became 37 s on 2017-01-01.
let e = cdf.leap_second_entry(41)
io.print(e.year, e.month, e.day) # 2017 1 1
io.print(e.delta_at_e7) # 370000000 — 37.0 s, in units of 1e-7 sTAI-UTC in EXACT nanoseconds for a UTC calendar date.
Pure integer arithmetic. Before 1972 the offset drifts with the day, which is what the drift columns encode; the MJD at noon is a half-integer, so the drift term is evaluated at twice scale and halved by folding the 2 into the 1e-7 -> 1e-9 conversion. Nothing rounds.
This is EXACT, and NASA's library is not. NASA computes the same quantity in double and truncates toward zero, so on 430 of the 4384 days between 1960-01-01 and 1972-01-01 its answer is 1 ns lower than this one. From 1972-01-01 onward the two agree on every day, because the offset is a whole number of seconds and the drift term vanishes. Use tai_minus_utc_ns_nasa_compat() when bit-identical agreement with NASA matters more than being right; the divergence is enumerated day by day in tests/space_cdf_leapseconds_test.cpp so it stays a reviewed fact, not a surprise.
year | UTC year. |
month | UTC month, 1-12. |
day | UTC day of month. |
TAI-UTC in nanoseconds; 0 before 1960-01-01, where the table does not reach.
O(leap_second_count()) — a linear scan of 42 entries.
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
# Since 2017-01-01, TAI runs 37 seconds ahead of UTC.
io.print(cdf.tai_minus_utc_ns(2026, 8, 28)) # 37000000000TAI-UTC in nanoseconds, computed the way NASA's CDF library computes it.
A faithful reproduction of the reference implementation: accumulate in double, then truncate toward zero. It exists so bit-identical agreement with NASA is available when that is what is wanted — and so the difference between the two is a tested, enumerated fact rather than a suspicion.
year | UTC year. |
month | UTC month, 1-12. |
day | UTC day of month. |
TAI-UTC in nanoseconds as NASA would compute it; 0 before 1960-01-01.
O(leap_second_count()) — a linear scan of 42 entries.
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
# Identical to the exact form from 1972 onward; both are 37 s in 2026.
io.print(cdf.tai_minus_utc_ns_nasa_compat(2026, 8, 28)) # 37000000000Where the table comes from.
NASA's published leap-second table URL.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
io.print(cdf.leap_seconds_url())The Updated: stamp carried inside the upstream file itself.
the upstream stamp, YYYYMMDD.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
io.print(cdf.leap_seconds_upstream_updated()) # 20161025SHA-256 of the NORMALIZED upstream table — comments and blank lines dropped, each remaining line's whitespace collapsed and trimmed.
Normalizing means a reformat or an edited comment raises no false alarm, while any change to the data does.
the 64-character lowercase hex digest.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
# scripts/check_leapseconds.sh recomputes this from the live file and compares.
io.print(cdf.leap_seconds_sha256())The date this copy of the table was last checked against upstream.
the verification date.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
let d = cdf.leap_seconds_verified_on()
io.print(d.year, d.month, d.day) # 2026 8 28The date through which this table is guaranteed COMPLETE.
Derived, not guessed: IERS announces a leap second at least six months ahead in Bulletin C. Upstream carried no entry after 2017-01-01 when we checked on leap_seconds_verified_on(), so none can occur before six months after that date. Past this date the table may be merely current rather than provably complete — ask leap_seconds_covers() before trusting a conversion of a future timestamp.
the last date the table is provably complete through.
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
let d = cdf.leap_seconds_known_good_through()
io.print(d.year, d.month, d.day) # 2027 2 28Whether a date falls in the range this table is provably complete for.
A conversion outside it is not necessarily wrong — it is merely unverifiable, because a leap second announced after leap_seconds_verified_on() would not be in this copy. Callers converting future timestamps should branch on this rather than take a silently stale answer.
year | UTC year. |
month | UTC month, 1-12. |
day | UTC day of month. |
true when the date is on or before leap_seconds_known_good_through().
O(1).
none.
systests/test_leapseconds.purrimport io
import space.cdf as cdf
io.print(cdf.leap_seconds_covers(2026, 8, 28)) # True
io.print(cdf.leap_seconds_covers(2099, 1, 1)) # False — beyond the guaranteeOpen a CDF by path.
Maps the file and parses every descriptor. Only CDF 3.x single-file, uncompressed-container, IEEE-encoded files are accepted; anything else is refused here with the ErrorCode that says why, so a later read cannot fail for a reason that was knowable at open.
path | the |
the opened file.
O(v) in the number of variables; no record data is read.
one descriptor list and name index; the file itself is memory-mapped, not read.
systests/test_cdf_read.purrimport io
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(len(cdf.var_names(f))) # 47Open a CDF from bytes already in memory.
The bytes are copied and owned by the File. This is the path every hostile-input test uses: ASan sees each byte of a heap buffer, but cannot see an over-read that stays inside a mapped page, so a bounds bug is only reliably caught here.
data | the file's bytes. |
size | how many. |
the opened file.
O(size) to copy, then O(v) in the number of variables.
one copy of the bytes, plus the descriptor list and name index.
systests/test_cdf_read.purrimport io
import space.cdf as cdf
# From purr, prefer cdf.open(path); this entry point exists for tests and embedders.
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(cdf.record_count(f, "Epoch")) # 44640The names of every variable in the file, rVariables first, in file order.
f | an opened file. |
the names.
O(v).
one list of v strings.
systests/test_cdf_read.purrimport io
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(cdf.var_names(f)[0]) # EpochHow many records a variable has.
f | an opened file. |
name | the variable. |
the record count; 0 for a variable with nothing written.
O(1) after the name lookup.
none.
systests/test_cdf_read.purrimport io
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(cdf.record_count(f, "F")) # 44640 — one day at one-minute cadenceThe CDF data type of a variable, by its format name.
f | an opened file. |
name | the variable. |
e.g. "CDF_REAL4", "CDF_EPOCH", "CDF_TIME_TT2000".
O(1) after the name lookup.
one short string.
systests/test_cdf_read.purrimport io
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(cdf.data_type_name(f, "Epoch")) # CDF_EPOCHThe shape values() will return for a variable.
Records first, then each dimension that varies per record, then one trailing axis when a value is itself several elements — the string length of a CHAR variable, or 2 for EPOCH16.
f | an opened file. |
name | the variable. |
the shape.
O(d) in the number of dimensions.
one short list.
systests/test_cdf_read.purrimport io
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
io.print(cdf.shape(f, "F")) # [44640] — a scalar per recordinline ::cheatah::ndarray::basic_ndarray< double > values(const File &f, const std::string &name)
#
Every record of a variable, as float64.
Lossless for every CDF type except CDF_INT8 and CDF_TIME_TT2000, which a double cannot hold exactly; those are refused with ErrorCode::LossyConversion — use values_i64(). CHAR data comes back as byte values with the string length as the last axis; EPOCH16 as a trailing axis of 2. This is the array cheatah-plot draws directly.
f | an opened file. |
name | the variable. |
an ndarray shaped as shape() describes. A copy of the file's data.
O(n) in the number of values — one decode pass.
the result array, exactly once; nothing intermediate.
systests/test_cdf_read.purrimport io
import ndarray
import space.cdf as cdf
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
let b = cdf.values(f, "F") # IMF magnitude, nT, one value per minute
io.print(b[0], b[1], b[2]) # 6.92 5.84 5.71The whole point of returning an ndarray: it goes straight into cheatah-plot.
import ndarray
import space.cdf as cdf
import plot
import plot.figure as figure
let f = cdf.open("omni_hro2_1min_20150101_v01.cdf")
let t = cdf.values(f, "Epoch")
let b = cdf.values(f, "F")
# Rebase onto hours: cheatah-plot has no time axis, and a raw CDF_EPOCH of 6.3e13 ms would
# render as one repeated tick label.
let n = ndarray.size_of(b)
let hours = ndarray.zeros([n])
for i in range(n) { hours[i] = (t[i] - t[0]) / 3600000.0 }
let fig = figure.line(figure.new_figure(), hours, b)
fig = figure.ylabel(fig, "|B| (nT)")
plot.save(fig, "omni_imf.png")inline ::cheatah::ndarray::basic_ndarray< long long > values_i64(const File &f, const std::string &name)
#
Every record of an integer variable, as int64 — the exact path for CDF_INT8 and TT2000.
Accepts every integer type; refuses REAL4/REAL8/EPOCH/EPOCH16 with ErrorCode::LossyConversion because truncating a float is not a conversion anyone asked for.
f | an opened file. |
name | the variable. |
an ndarray of int shaped as shape() describes. A copy of the file's data.
O(n) in the number of values — one decode pass.
the result array, exactly once; nothing intermediate.
systests/test_cdf_read.purrimport io
import ndarray
import space.cdf as cdf
# TT2000 is nanoseconds since J2000; an int64 holds it exactly, a double does not.
let f = cdf.open("rbsp-a_density_emfisis-l4_20130101_v1.5.17.cdf")
let t = cdf.values_i64(f, "Epoch")
io.print(t[0])Constants & variables
First magic number of a CDF 3.x file, at byte offset 0.
First magic number of a CDF 2.6/2.7 file — the 32-bit-offset generation.
First magic number of a pre-2.6 file. Not supported; see ErrorCode::UnsupportedPreV26.
Second magic number: the file's records are stored uncompressed.
Second magic number: the whole file is compressed and a CCR follows the magic.
Byte offset of the CDR, which always follows the two 4-byte magic numbers.
Bytes a checksummed file carries past GDR.eof — an MD5 digest, not a CDF record.
True when this build targets a little-endian host.
CDF stores no encoding meaning "native", so the Host selector has to be resolved against the machine actually doing the reading.
CDR flags bit 0: set when the file's majority is row-major.
CDR flags bit 1: set when all data lives in this one file rather than .v*/.z* companions.
CDR flags bit 2: set when the file carries a trailing checksum.
CDR flags bit 3: set when that checksum is MD5. Meaningful only with kCdrFlagChecksum.
How many ErrorCode enumerators exist, including None.
The unit tests assert one crafted failure per code against this, so a new code cannot be added without a test that provokes it.
Types
The internal record types, as written in the 4-byte big-endian field at record offset 8.
UnusedInternal is -1 and marks a hole left by a rewrite; such records are skipped, never parsed. Every other value is a real record with a defined layout.
The CDF data types.
The numbering is the format's, not ours — see the file docs for why REAL4/FLOAT and REAL8/DOUBLE are distinct values with identical layouts.
The byte orders and floating-point formats a CDF's values may be stored in.
"NETWORK" is not XDR despite the name — it is plain big-endian IEEE-754. That is worth stating because assuming otherwise invents a whole conversion layer that is not needed.
The four ways a CDF's bytes can actually need decoding.
Twenty-one encodings collapse to four decode behaviours, which is the single simplification that keeps the decoder small: what matters is integer byte order and whether floats are IEEE or one of the two VAX formats.
How a multi-dimensional variable's values are laid out within one record.
What a variable's unwritten records contain.
The compression algorithms CDF defines. Only Gzip occurs in the public archive.
Everything that can be wrong with a CDF we are asked to read.
One flat enumeration rather than exception subtypes: a caller almost always wants to report what happened and where, not to branch on the kind, and a closed set can be table-tested for reachability — every code below is provoked by a crafted file in the unit tests, so none of them is aspirational.
