Formats
Six ways to get the same numbers. Every format carries the served values exactly, so a CSV file and a binary download agree to the last bit.
Choosing a format#
Set format= on a light-curve request. The default is the binary sp3c, which the website uses.
format | Best for | Content-Type |
|---|---|---|
csv | Spreadsheets, pandas, a quick look | text/csv |
ecsv | astropy tables with units and types | text/plain |
json | Scripts in any language | application/json |
parquet | Large analyses, pandas and pyarrow, archiving | application/vnd.apache.parquet |
arrow | Zero-copy loading into Arrow tools | application/vnd.apache.arrow.stream |
sp3c | Browsers and the fastest transfer; read it with 20 lines of code | application/vnd.asassn.sp3c |
The batch stream sp3f is Coming soon.
Exact values#
The server decodes each light curve once and writes the same arrays into every format:
- Floats in text use the shortest decimal that reads back to the same binary value. A 64-bit column such as
bjdis written like Python'srepr; a 32-bit column such asmaggets the shortest of 6 to 9 significant digits that parses back to the same float32. Read 32-bit columns as float32 and you recover the served bits exactly. - Missing values (NaN) are empty fields in CSV,
nanin ECSV (as astropy expects) andnullin JSON. - Coded columns such as
cameraandbandare written as strings in the text formats. - Flags such as
detectedare 0 or 1. - Solar-system ids (
object_id) are decimal strings everywhere, because they need 63 bits.
The magnitudes are not rounded for display. A value such as 14.036707 is the float32 the decoder produced, not a measurement good to a micromagnitude; use mag_err for the uncertainty.
CSV#
A CSV file starts with # key: value comment lines that describe the light curve, followed by a header row and one row per measurement:
| Comment key | Content |
|---|---|
format | SP3-CSV 1 |
kind, dataset | star_lc and stars, or ss_lc and ss |
release_id, serve_id | The release and serving identity (versioned URLs) |
n | Number of rows |
object | The star or object record (JSON) |
selection | Every parameter that shaped the curve, defaults included (JSON) |
curves | Stars: the curves (field, band, aperture) with their row ranges (JSON) |
summary | Row counts (JSON). Stars: decoded, returned, excluded and detected. Solar system: rows, detections and recommended rows, in total and per band. |
provenance | Decoder identity, quality-flag version, error model, sidecar checksum and server version (JSON) |
citation | What to cite |
units | Unit of each column (JSON) |
A star CSV also expands the curve column into field, band and aperture columns, so each row stands alone.
import pandas as pd
lc = pd.read_csv("https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc?format=csv", comment="#")
print(lc.dtypes)import json, urllib.request
url = "https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc?format=csv"
meta = {}
for line in urllib.request.urlopen(url).read().decode().splitlines():
if not line.startswith("# "):
break
key, _, value = line[2:].partition(": ")
meta[key] = json.loads(value) if value[:1] in "[{" else value
print(meta["selection"], meta["units"]["mag"])Spreadsheet programs show the comment lines as text rows above the table. To read the values as float32 exactly, pass dtype={"mag": "float32", ...} to pandas.
ECSV#
ECSV 1.0 adds a YAML header with each column's data type, unit and description, and the light-curve header as table metadata. astropy reads it directly:
from astropy.table import Table
t = Table.read("https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc?format=ecsv", format="ascii.ecsv")
print(t["mag"].dtype, t["mag"].unit, t.meta["release_id"])JSON#
JSON holds the header and one array per column:
{"header": {"format": "SP3-JSON", "version": 1, "kind": "star_lc", "release_id": "…", "serve_id": "…",
"object": {…}, "selection": {…}, "curves": […], "n": 3,
"columns": [{"name": "bjd", "type": "f8", "unit": "d", "desc": "BJD_TDB at mid-exposure"}, …]},
"columns": {"bjd": [2456024.1328295474, …], "mag": [14.036707, …], "camera": ["bA", …],
"field": ["F0845+18_0428", …], …}}Coded columns are already strings, and a star's curve is expanded into field, band and aperture, as in CSV. Column-wise arrays are much smaller than a list of row objects. JSON.parse returns float64 numbers; Math.fround(x) (or numpy.float32(x)) recovers the exact value of a 32-bit column.
Parquet and Arrow#
Parquet files have one row group, zstd level 1 compression and dictionary-encoded strings. Arrow responses are an IPC stream with uncompressed buffers. Both keep the binary types of the column tables, store each column's unit and description in its field metadata, and carry the full light-curve header as JSON in the schema metadata key sp3:
import io, json, urllib.request
import pyarrow.parquet as pq
url = "https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc?format=parquet"
table = pq.read_table(io.BytesIO(urllib.request.urlopen(url).read()))
header = json.loads(table.schema.metadata[b"sp3"])
df = table.to_pandas()
print(header["release_id"], len(df))import json, urllib.request
import pyarrow as pa
url = "https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc?format=arrow"
table = pa.ipc.open_stream(urllib.request.urlopen(url).read()).read_all()
print(json.loads(table.schema.metadata[b"sp3"])["n"], table.schema)SP3C: the binary light-curve format#
SP3C v1 holds one light curve as a JSON header followed by one binary array per column. A browser or NumPy can use most columns in place, without copying or parsing. A typical star light curve takes about 18 bytes per row after brotli compression; the same row in CSV is about 100 characters before compression.
Offsets are from the start of the payload. Columns begin at byte 8 + H, which is a multiple of 8.
| Offset | Size | Content |
|---|---|---|
| 0 | 4 | Magic bytes SP3C |
| 4 | 4 | H, the header length, unsigned 32-bit little-endian |
| 8 | H | Header: UTF-8 JSON, padded with spaces so that (8 + H) % 8 == 0 |
8 + H | … | Columns. Column k starts at 8 + H + columns[k].offset (a multiple of 8) and is zero-padded to a multiple of 8 bytes. |
Header#
| Key | Content |
|---|---|
format, version | "SP3C" and 1 |
kind, dataset | star_lc / stars or ss_lc / ss |
release_id, serve_id | Release and serving identity |
n | Number of rows |
object | Star or object record |
selection | Every parameter that shaped the curve, defaults included |
columns | Column descriptors, in order (below) |
dictionaries | String tables for coded columns, e.g. {"camera": ["bA", "bC", …]} |
provenance | decoder, quality_version, sidecars_sha256, server_version |
citation | What to cite |
curves | Stars only: [{entry_id, field, band, aperture, start, count}], the row range of each curve |
summary | Row counts. Stars: {n_decoded, n_returned, n_excluded, n_detected}. Solar system: {n_rows, n_detected, n_recommended, bands}. |
Readers must ignore header keys they do not know; new keys can appear without a version change.
Columns#
Each descriptor is {name, type, offset, nbytes, shuffled, dict?, unit?, desc?}. The types are:
type | Values | NumPy / JavaScript |
|---|---|---|
f8, f4 | 64- and 32-bit IEEE floats | <f8, <f4 / Float64Array, Float32Array |
i4, u4 | 32-bit integers | <i4, <u4 / Int32Array, Uint32Array |
i2, u2 | 16-bit integers | <i2, <u2 / Int16Array, Uint16Array |
u1 | bytes | u1 / Uint8Array |
str | UTF-8 strings | u4 offsets[n+1], then the bytes |
- All numbers are little-endian. There are no 64-bit integer columns, because JavaScript cannot hold them in a typed array without BigInt; 63-bit ids travel as strings in the header.
- Shuffled columns (
shuffled: true) are stored asitemsizeplanes of n bytes: plane b holds byte b of every value. Shuffling groups similar bytes together, so the column compresses much better. To read one, transpose the planes back (the readers below show how). - String columns hold n+1 unshuffled
u4offsets followed by the UTF-8 bytes; string i isbytes[off[i]:off[i+1]]. - Coded columns have
dict: the value is an index intoheader.dictionaries[dict].
Reader rules#
- Reject a payload whose magic is not
SP3C, whose majorversionis not 1, or that has a column type you do not know. - Ignore unknown header keys and unknown descriptor keys.
- Limits: n ≤ 16,000,000 rows and 512 MiB per payload.
Readers#
These readers are complete and tested against the golden files in the server's test suite. Save one and use it as it stands.
"""Minimal SP3C v1 reader (numpy only). Sky Patrol V3 docs: /docs/formats.html"""
import json
import struct
import numpy as np
TYPES = {"f8": "<f8", "f4": "<f4", "i4": "<i4", "u4": "<u4", "i2": "<i2", "u2": "<u2", "u1": "u1"}
def read_sp3c(buf):
"""Return (header, columns) from one SP3C payload; str columns become lists of str."""
buf = memoryview(buf).cast("B")
if bytes(buf[:4]) != b"SP3C":
raise ValueError("not an SP3C payload")
(hlen,) = struct.unpack_from("<I", buf, 4)
header = json.loads(bytes(buf[8:8 + hlen]))
if int(str(header["version"]).split(".")[0]) != 1:
raise ValueError("unsupported SP3C version %r" % header["version"])
base, n, cols = 8 + hlen, header["n"], {}
for c in header["columns"]:
if c["offset"] % 8:
raise ValueError("misaligned column %r" % c["name"])
at = base + c["offset"]
if c["type"] == "str": # u4 offsets[n+1], then UTF-8 bytes
offs = np.frombuffer(buf, "<u4", n + 1, at).tolist()
blob = bytes(buf[at + 4 * (n + 1):at + c["nbytes"]])
cols[c["name"]] = [blob[offs[i]:offs[i + 1]].decode("utf-8") for i in range(n)]
continue
if c["type"] not in TYPES:
raise ValueError("unknown column type %r" % c["type"])
dt = np.dtype(TYPES[c["type"]])
if c.get("shuffled") and dt.itemsize > 1: # itemsize planes of n bytes each
planes = np.frombuffer(buf, np.uint8, n * dt.itemsize, at).reshape(dt.itemsize, n)
cols[c["name"]] = np.ascontiguousarray(planes.T).view(dt).reshape(n)
else:
cols[c["name"]] = np.frombuffer(buf, dt, n, at)
return header, cols
def expand(header, cols, name):
"""Strings for a dictionary-coded column such as 'camera' or 'band'."""
spec = next(c for c in header["columns"] if c["name"] == name)
return np.asarray(header["dictionaries"][spec["dict"]])[cols[name]]// Minimal SP3C v1 reader for browsers and Node 18+. Sky Patrol V3 docs: /docs/formats.html
const TYPES = {f8: Float64Array, f4: Float32Array, i4: Int32Array, u4: Uint32Array,
i2: Int16Array, u2: Uint16Array, u1: Uint8Array};
export function readSP3C(buf) { // buf: ArrayBuffer, e.g. await (await fetch(url)).arrayBuffer()
const dv = new DataView(buf);
if (dv.getUint32(0, true) !== 0x43335053) throw new Error('not an SP3C payload'); // "SP3C"
const hlen = dv.getUint32(4, true), base = 8 + hlen;
const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, hlen)));
if (parseInt(header.version, 10) !== 1) throw new Error('unsupported SP3C version ' + header.version);
const n = header.n, cols = {};
for (const c of header.columns) {
if (c.offset % 8) throw new Error('misaligned column ' + c.name);
const at = base + c.offset;
if (c.type === 'str') { // u4 offsets[n+1], then UTF-8 bytes
const off = new Uint32Array(buf, at, n + 1), bytes = new Uint8Array(buf, at + 4 * (n + 1)), td = new TextDecoder();
cols[c.name] = Array.from({length: n}, (_, i) => td.decode(bytes.subarray(off[i], off[i + 1])));
continue;
}
const T = TYPES[c.type];
if (!T) throw new Error('unknown column type ' + c.type);
if (!c.shuffled || T.BYTES_PER_ELEMENT === 1) { cols[c.name] = new T(buf, at, n); continue; } // zero-copy
const w = T.BYTES_PER_ELEMENT, src = new Uint8Array(buf, at, n * w), out = new Uint8Array(n * w);
for (let b = 0; b < w; b++) for (let i = 0; i < n; i++) out[i * w + b] = src[b * n + i];
cols[c.name] = new T(out.buffer);
}
return {header, cols};
}
export function expand(header, cols, name) { // strings of a dictionary column ('camera', 'band', ...)
const dict = header.dictionaries[header.columns.find(c => c.name === name).dict];
return Array.from(cols[name], k => dict[k]);
}import urllib.request
from sp3c_reader import read_sp3c, expand # the file above
req = urllib.request.Request("https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc",
headers={"Accept-Encoding": "identity"})
header, cols = read_sp3c(urllib.request.urlopen(req).read())
camera = expand(header, cols, "camera")
print(header["n"], cols["mag"].dtype, camera[:3])import {readSP3C, expand} from './sp3c_reader.js'; // the file above
const res = await fetch('https://asassn-v3.ifa.hawaii.edu/v3/stars/11010359/lc');
const {header, cols} = readSP3C(await res.arrayBuffer()); // fetch undoes the compression
console.log(header.n, cols.mag.constructor.name, expand(header, cols, 'camera').slice(0, 3));urllib does not decompress, so the Python example asks for an uncompressed response; with requests or a browser, compression is handled for you.
SP3F: the batch stream Coming soon#
The batch endpoints return many light curves as one SP3F v1 stream, so a client can process each curve as it arrives.
One stream header, then frames until the trailer.
Frame kind | Payload |
|---|---|
| 1 | One light curve as SP3C. Its header adds item: {index, id}, the position and id in your request. |
| 2 | An error for one item, as JSON (error shape) plus item |
| 3 | The trailer, as JSON: {n_items, n_ok, n_error, n_rows, request_id} |
Items come back in request order. A stream without a trailer has failed, even if it ended cleanly: retry the items you did not receive.
"""Minimal SP3F v1 stream reader. Sky Patrol V3 docs: /docs/formats.html"""
import json
import struct
from sp3c_reader import read_sp3c # the SP3C reader on the same page
def _exact(stream, n):
data = b""
while len(data) < n:
chunk = stream.read(n - len(data))
if not chunk:
raise EOFError("SP3F stream ended early: no trailer, treat the batch as failed")
data += chunk
return data
def read_sp3f(stream):
"""Yield ('item', header, cols), ('error', dict) and finally ('trailer', dict)."""
head = _exact(stream, 8)
if head[:4] != b"SP3F" or head[4] != 1:
raise ValueError("not an SP3F v1 stream")
while True:
frame = _exact(stream, 8)
kind, (length,) = frame[0], struct.unpack_from("<I", frame, 4)
payload = _exact(stream, length + (-length) % 8)[:length]
if kind == 1:
header, cols = read_sp3c(payload)
yield "item", header, cols
elif kind == 2:
yield "error", json.loads(payload)
elif kind == 3:
yield "trailer", json.loads(payload)
return
else:
raise ValueError("unknown SP3F frame kind %d" % kind)