Skip to content

SDSS spectra as virtual references — a public dataset

A read-anonymous Icechunk store of byte-range pointers into the original SDSS DR17 FITS files. Tens of kilobytes of references, no reshard, no download step: open it and stream 3,840 galaxy spectra.

400050006000700080009000Hα 6563[O III] 5007

Fiber 900 of the six-plate store — one row of flux[3840, 3841], median-binned 5:1 for drawing. Dashed rules mark the rest wavelengths of Hα (6563 Å) and [O III] (5007 Å); this object is redshifted, so its own lines sit to the right of them.

What it is

SDSS spPlate frames hold ~640 spectra apiece on a shared log-wavelength grid, stored fiber-major. That means a block of fibers is already a contiguous byte range — so you can hand out chunks of an archival FITS file by doing arithmetic on offsets, without rewriting a pixel.

That is what these stores are: kilobyte-sized Icechunk repositories holding a chunk manifest of byte ranges, built by pointing VirtualiZarr at the FITS headers. Open one and you get an ordinary zarr-v3 array; read it and the bytes come out of the FITS files themselves.

Store Shape Chunk Fibers/chunk Pixels from
sdss_dr17_p0266_refs 640 × 3864 64 × 3864 64 data.sdss.org
sdss_dr17_6plate_refs 3840 × 3841 1 × 3841 1 data.sdss.org
sdss_dr17_6plate_mirror_refs 3840 × 3841 1 × 3841 1 this bucket

All three expose one float32 variable, flux, as (fiber, wavelength), under gs://insitubatch-bench-insitubatch/astronomy/. Start with the mirror store: same geometry as 6plate, but its references point at our copy of the FITS files in the same bucket, so it is one provider and in-region from GCP.

Open one

No credentials, no requester-pays, no build step. pip install icechunk zarr is the whole dependency list — the FITS stack was only needed to write the references.

import icechunk, zarr

BUCKET = "insitubatch-bench-insitubatch"
PREFIX = "astronomy/sdss_dr17_6plate_mirror_refs"
PIXELS = f"https://storage.googleapis.com/{BUCKET}/astronomy/sdss/dr17/"

config = icechunk.RepositoryConfig.default()
config.set_virtual_chunk_container(
    icechunk.VirtualChunkContainer(PIXELS, icechunk.http_store())
)
repo = icechunk.Repository.open(
    icechunk.gcs_storage(bucket=BUCKET, prefix=PREFIX, anonymous=True),
    config=config,
    authorize_virtual_chunk_access=icechunk.containers_credentials({PIXELS: None}),
)

flux = zarr.open_array(repo.readonly_session("main").store, path="flux", mode="r")
flux.shape        # (3840, 3841) float32, chunks (1, 3841)
flux[0]           # one spectrum

Each store records the prefix its virtual chunks resolve against — print(repo.config.virtual_chunk_containers) if you need it. For the two data.sdss.org-backed stores, set PIXELS = "https://data.sdss.org/".

Wavelengths

Uniform in log₁₀(λ), the SDSS convention: bin i is 10**(COEFF0 + i*COEFF1) Å. Each store carries its own solution as array attributes, so read them off flux.attrs rather than hard-coding the table below:

import numpy as np

c0, c1, n_bins = 3.5797, 0.0001, 3841      # == flux.attrs["COEFF0"], ["COEFF1"], shape[1]
wavelength_angstrom = 10.0 ** (c0 + c1 * np.arange(n_bins))
wavelength_angstrom[[0, -1]]               # array([3799.3, 9198.1])
Store COEFF0 COEFF1 Bins Range
p0266 3.5785 0.0001 3864 3789 – 9221 Å
6plate, 6plate-mirror 3.5797 0.0001 3841 3799 – 9198 Å

The six-plate COEFF0 is the shared window start — the largest of the six plates' own start wavelengths, which is also the crop offset into each source frame. Fluxes are in SDSS units of 10⁻¹⁷ erg s⁻¹ cm⁻² Å⁻¹; bad pixels are NaN.

Two layouts, one set of bytes

The same FITS files produce two genuinely different chunk geometries, and you cannot have both at once:

  • One plate → 64 fibers per chunk. Full wavelength width. One read decodes 64 spectra, so per-read overhead amortizes across many samples.
  • Six plates → one fiber per chunk. Plates cover slightly different wavelength ranges, so combining them means cropping each to the shared window — and that crop breaks the fiber contiguity the first layout depends on. You get archive-scale concatenation and pay one ranged read per spectrum.

The crop is exact rather than interpolated: every plate uses the same dloglam step and their start wavelengths differ by whole bins, so the windows land on one grid with no resampling. Six plates is 3,840 spectra; the same construction runs out to the ~2,800-plate archive with the manifest still measured in kilobytes.

What it costs to read

One fiber per chunk means one ranged GET per spectrum, so the six-plate store is latency-bound, not bandwidth-bound. A full pass over all 3,840 spectra (59 MB in 3,840 reads), from a VM in us-central1, medians of five interleaved runs:

Reader Concurrency Full pass Spectra/s
zarr, 64-row slices 10 (default) 17.9 s 215
zarr, 64-row slices 32 12.2 s 314
insitubatch, shuffled batches 32 7.3 s 530

Both readers knee around 32 concurrent reads. If you use plain zarr, raise async.concurrency — the default of 10 costs about a third of the throughput here.

Everything above uses a bare icechunk.http_store(), exactly as the recipes on this page do. Resist the urge to hand it a tuned connection pool without measuring: capping pool_max_idle_per_host below your read concurrency makes a sustained reader reconnect constantly, which measured 12% slower than the default for us.

Read this as parity, not a speed win

The only thing tuned on the zarr side is async.concurrency. Slice width, access pattern, thread-pool settings and the store backend are all untouched, and any of them could narrow or close the gap — this is two reasonable configurations measured fairly, not a tuned-versus-tuned result. Parity is the honest reading, and it is enough. At one sample per chunk there is nothing to amortize within a chunk and no redundant reads to de-duplicate, so a loader has no structural advantage to convert; reading at roughly the speed of a straightforward zarr read is the expected outcome. The case for the loader here is what you get at that speed — shuffled, split-aware, chunk-aligned batches with transforms, no reshard, flat memory — not the throughput itself.

Where chunk layout actually shows up

One sample per chunk is the geometry where a batch loader has least to offer. Chunk layout is a much bigger lever than the choice of reader, and that comparison is cleaner because it is the same reader against itself.

To isolate what the chunk geometry alone is worth, here is one plate (0266, 640 spectra) built both ways from the same mirrored bytes, verified to deliver byte-identical arrays:

Reader Layout Full pass Spectra/s
insitubatch 64 fibers/chunk 0.11 s 5,835
insitubatch 1 fiber/chunk 1.26 s 507
raw zarr 64 fibers/chunk 0.43 s 1,484
raw zarr 1 fiber/chunk 1.65 s 388

Same bytes, same provider, same 640 spectra: 11.5× from the chunk geometry alone for insitubatch, and 3.8× for raw zarr over the identical change. Both readers gain a lot; the claim worth standing behind is that the layout dominates, not that one reader beats the other. Use p0266 if this is the regime you want to measure. (These passes are short enough that cold start dominates the spread, so treat the ratios as the durable part and the absolute times as indicative.)

As training batches

Any loader works on the array above. This is insitubatch, streaming shuffled, chunk-aligned splits straight from the store — complete and standalone, so it runs as-is:

import icechunk
from insitubatch import InSituDataset, open_geometries, split_by_chunk

BUCKET = "insitubatch-bench-insitubatch"
PREFIX = "astronomy/sdss_dr17_6plate_mirror_refs"
PIXELS = f"https://storage.googleapis.com/{BUCKET}/astronomy/sdss/dr17/"

config = icechunk.RepositoryConfig.default()
config.set_virtual_chunk_container(
    icechunk.VirtualChunkContainer(PIXELS, icechunk.http_store())
)
repo = icechunk.Repository.open(
    icechunk.gcs_storage(bucket=BUCKET, prefix=PREFIX, anonymous=True),
    config=config,
    authorize_virtual_chunk_access=icechunk.containers_credentials({PIXELS: None}),
)
store = repo.readonly_session("main").store

geoms = open_geometries(store, variables=["flux"], sample_axis=0)
ds = InSituDataset(
    store,
    split_by_chunk(geoms["flux"], fractions=(0.8, 0.1, 0.1)),
    geometries=geoms,
    batch_size=64,
    max_inflight=32,   # concurrent ranged GETs -- the knob that matters here
)
for batch in ds.train:
    x = batch.arrays["flux"]        # (64, 3841) float32

A worked example — spectral reconstruction with a conv autoencoder against a PCA baseline, mirroring astroML's compute_sdss_pca workflow — ships in the repo:

uv run python -m examples.sdss.train_torch --source published

See Examples for the flags and the build-it-yourself path.

Provenance and licence

SDSS DR17, public domain: "All SDSS data released in our public data releases are considered in the public domain." Work using these data should cite SDSS per the survey's guidance. Sources, the mirrored file list and citation pointers are in PROVENANCE.md beside the data; bucket-level docs in astronomy/README.md.

Reading big-endian FITS correctly through the virtual chain needs VirtualiZarr ≥ 2.7.2 and zarr ≥ 3.3. Those releases are why this dataset exists in a usable form — thanks to the VirtualiZarr and zarr-python maintainers for turning the fixes around quickly, and to Martin Durant, whose kerchunk.fits does the header parsing underneath.