Skip to content

mnmly/SwiftFITS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

SwiftFITS

πŸ“– API documentation

A small, reusable Swift package that wraps CFITSIO (the standard C library for FITS files) with an idiomatic, modern-Swift API: open a FITS file, navigate its HDUs, and read table columns and image data.

It is a generic FITS bridge β€” it knows nothing about any specific survey or instrument. Domain logic (e.g. assembling a DESI spectrum) belongs in the consumer, built on top of these primitives.

Requirements

  • macOS 13+ on Apple Silicon, Swift 6 toolchain.

  • CFITSIO via Homebrew:

    brew install cfitsio

    The CFITSIO system-library target finds the headers and library through pkg-config. If pkg-config can't locate it, point it at the keg:

    export PKG_CONFIG_PATH=/opt/homebrew/opt/cfitsio/lib/pkgconfig

Usage

FITSFile is an actor: a CFITSIO handle is not thread-safe, so every call on one file is serialized. Different files are independent actors and run in parallel. Call sites use try await, and the whole surface uses Swift 6 typed throws (throws(FITSError)).

import SwiftFITS

let file = try FITSFile(path: "spectrum.fits")

// Navigate HDUs.
let count = try await file.hduCount
try await file.selectHDU(named: "SPECTRUM")          // by EXTNAME
// or: try await file.selectHDU(at: 2)               // 1-based index

// Read a table column. The element type is inferred from the result β€”
// no `Type.self` argument. CFITSIO converts the on-disk type as needed.
let channels: [Int32] = try await file.readColumn("CHANNEL")
let rate:     [Float] = try await file.readColumn("RATE")
print("rows:", try await file.rowCount, "first channel:", channels.first ?? -1)

Reading image data:

try await file.selectHDU(at: 1)                       // an IMAGE HDU
let dims = try await file.imageDimensions             // [fastAxis, rows]
let everything: [Float] = try await file.readImage()  // row-major, fast axis contiguous
let oneRow:     [Float] = try await file.readImageRow(0)   // 0-based row index

Opening from bytes in memory

For data you already hold (e.g. an HTTP range-read), open without touching disk:

let bytes: Data = /* fetched however you like */
let file = try FITSFile(data: bytes)

The bytes are copied into a buffer the handle owns for its lifetime. (CFITSIO's in-memory driver keeps internal pointers to that buffer, so SwiftFITS manages its lifetime for you β€” see the note in FITSFile.swift.)

Reading a remote file with byte-range reads

For a large file on a server that supports HTTP Range (most do), open it lazily: CFITSIO seeks to exactly the bytes it needs β€” the header blocks, the column you read, the one image row β€” and SwiftFITS fetches only those spans. A single object can be pulled from a multi-gigabyte coadd while transferring a few megabytes.

let url = URL(string: "https://example.org/big-coadd.fits")!
let file = try FITSFile(remoteURL: url)        // nothing downloaded yet

try await file.selectHDU(named: "FIBERMAP")
let ids: [Int64] = try await file.readColumn("TARGETID")   // fetches ~that column
try await file.selectHDU(named: "B_FLUX")
let row: [Float] = try await file.readImageRow(784)        // fetches ~one row

Measured: reading one DESI spectrum out of the public 1.1 GB DR1 coadd transfers β‰ˆ14 MB (~1.2% of the file).

The transport is pluggable via FITSRangeReader (synchronous random-access byte source). HTTPRangeReader is the built-in default; supply your own to read from a CDN, an authenticated endpoint, or a cache. blockSize: (default 1 MiB) tunes how reads are coalesced into range requests.

Range reads block the calling task while a request is in flight (CFITSIO's I/O is synchronous). Because FITSFile is an actor, calls on one file are serialized; use separate FITSFiles (each on its own task) to fetch spectra concurrently.

API surface

public enum HDUType: Sendable { case image, asciiTable, binaryTable }

public enum FITSError: Error, Sendable, CustomStringConvertible {
    case status(code: Int32, message: String)   // CFITSIO status + message
    case columnNotFound(String)
    case typeMismatch
}

public protocol FITSScalar: Sendable { static var fitsTypeCode: Int32 { get } }
// Conformances: Int32, Int64, Float, Double.

/// Synchronous random-access byte source for a remote/custom FITS file.
public protocol FITSRangeReader: Sendable {
    func totalLength() throws -> Int
    func readRange(offset: Int, count: Int) throws -> Data
}
public struct HTTPRangeReader: FITSRangeReader { /* URLSession + Range requests */ }

public actor FITSFile {
    public init(path: String) throws(FITSError)
    public init(data: Data)   throws(FITSError)
    public init(rangeReader: FITSRangeReader, blockSize: Int = 1 << 20) throws(FITSError)
    public init(remoteURL: URL, blockSize: Int = 1 << 20) throws(FITSError)

    public var hduCount: Int            { get throws(FITSError) }
    public func selectHDU(named name: String) throws(FITSError)
    public func selectHDU(at index: Int)      throws(FITSError)   // 1-based
    public var currentHDUType: HDUType  { get throws(FITSError) }

    public var rowCount: Int { get throws(FITSError) }
    public func columnNumber(named name: String) throws(FITSError) -> Int
    public func readColumn<T: FITSScalar>(_ name: String) throws(FITSError) -> [T]

    public var imageDimensions: [Int] { get throws(FITSError) }
    public func readImage<T: FITSScalar>() throws(FITSError) -> [T]
    public func readImageRow<T: FITSScalar>(_ rowIndex: Int) throws(FITSError) -> [T]
}

Testing

swift test

The default suite runs offline against a bundled fixture (Tests/SwiftFITSTests/Fixtures/sample.fits) covering HDU navigation, table column reads (with type conversion), image reads, in-memory open, and error paths.

An opt-in integration test reads one spectrum out of the real ~1.1 GB DESI DR1 coadd over HTTP range reads (no full download β€” it transfers β‰ˆ14 MB). It needs network access and is skipped unless enabled:

RUN_DESI_INTEGRATION=1 swift test

Documentation

Full API reference is published at mnmly.github.io/SwiftFITS, built from DocC and deployed by .github/workflows/docs.yml on every push to main.

Build the site locally with:

Scripts/build_docs.sh           # static site into ./docs
Scripts/build_docs.sh preview   # live-reloading local server

Design notes

  • Built in Swift 6 language mode with strict concurrency: no warnings, no @unchecked Sendable, no data-race suppressions.
  • SwiftFITS calls CFITSIO's canonical ff* C functions directly. The fits_* names are C macros that do not import into Swift (some are function-like, e.g. fits_open_file); each call documents its fits_* equivalent in a comment.
  • Unmanaged CFITSIO resources are owned by a small RAII class and released in its deinit, which keeps cleanup portable (no macOS 15.4+ isolated deinit) and race-free.
  • Range reads are implemented by registering a custom CFITSIO I/O driver (fits_register_driver) whose seek/read callbacks are served by a FITSRangeReader, with block-aligned caching. This reuses all of CFITSIO's parsing β€” only the bytes it actually reads cross the network.

About

Idiomatic Swift 6 wrapper over CFITSIO: actor-based FITS reader with HDU navigation, generic column/image reads, and lazy HTTP range reads.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors