Tables

fitsy reads both binary tables (XTENSION = 'BINTABLE') and ASCII tables (XTENSION = 'TABLE') into the fitsy.BinTable and fitsy.AsciiTable types.

Both expose the same access patterns:

  • len(tbl) – number of rows.

  • tbl.column_names – list of column names in declared order.

  • tbl.data – structured numpy.ndarray (one record per row).

  • tbl["COLNAME"] – a 1-D numpy.ndarray for that column.

  • tbl[i] – a row dict {colname: value}.

  • tbl[a:b] – a list of row dicts.

Column edits do not currently round-trip through writeto – table data is re-emitted from the bytes captured at load time. Use fitsy.write() with a fresh fitsy.BinTableBuilder to author a new table.

An ASCII integer column reads back as float64, and a cell that matched TNULL is nan. The Rust API keeps the two apart: AsciiTableHdu::cell_value returns None for a TNULL match and an AsciiCell::Int otherwise.

Examples

Reading a binary table:

"""Read a binary table: columns, rows, and structured arrays.

Run from the repository root:

    python examples/python/tables.py
"""

import os
import tempfile

import fitsy
import numpy as np

with tempfile.TemporaryDirectory() as td:
    path = os.path.join(td, "catalog.fits")
    fitsy.write(
        path,
        # A table-only file needs no placeholder image: `write` adds an
        # empty primary HDU when the first builder is not an image.
        [
            fitsy.bintable(
                {
                    "RA": np.array([10.0, 20.0, 30.0]),
                    "DEC": np.array([-5.0, 0.0, 5.0]),
                    "NAME": ["alpha", "beta", "gamma"],
                },
                extname="CATALOG",
            ),
        ],
    )

    with fitsy.open(path) as f:
        tbl = f["CATALOG"]
        print("columns:", tbl.column_names)
        print("nrows  :", len(tbl))

        # Whole-table structured numpy array (zero-copy where possible).
        arr = tbl.data
        print("structured dtype:", arr.dtype)

        # Single row by integer index.
        row = tbl[0]
        print("row 0 :", row["RA"], row["DEC"], row["NAME"])

        # Slice of rows -> list of row dicts.
        first_two = tbl[:2]
        print("first two RAs:", [r["RA"] for r in first_two])

        # Pull a column by name -> 1-D numpy array.
        ras = tbl["RA"]
        print("RA column dtype:", ras.dtype)

Writing and reading an ASCII table, including the TNULL sentinel that marks an undefined numeric cell:

"""Write and read an ASCII table: fixed-width columns and TNULL.

Run from the repository root:

    python examples/python/ascii_tables.py
"""

import os
import tempfile

import fitsy

with tempfile.TemporaryDirectory() as td:
    path = os.path.join(td, "catalog.fits")

    # `ascii_table` picks a TFORM code per column from the value kind.
    # `formats` overrides that choice. A numeric column that holds an
    # undefined cell needs a matching `tnulls` entry, because Standard
    # Sec.7.2.5 gives a blank numeric field the value zero, so TNULL is
    # the only marker of an undefined value. The sentinel must fit the
    # field width, which is why COUNT declares `I6` rather than taking
    # the narrower automatic width.
    fitsy.write(
        path,
        [
            fitsy.ascii_table(
                {
                    "NAME": ["alpha", "beta", "gamma"],
                    "COUNT": [12, None, 37],
                    "FLUX": [1.5, 2.25, 3.125],
                },
                formats={"COUNT": "I6", "FLUX": "F9.3"},
                tnulls={"COUNT": "---"},
                units={"FLUX": "Jy"},
                extname="CATALOG",
            ),
        ],
    )

    with fitsy.open(path) as f:
        tbl = f["CATALOG"]
        print("columns:", tbl.column_names)
        print("nrows  :", tbl.n_rows)

        # An ASCII integer column reads back as float64, and a cell
        # that matched TNULL is nan. A string cell keeps the padding
        # that its fixed-width field carries.
        counts = tbl.column("COUNT")
        print("COUNT dtype:", counts.dtype, " values:", counts)

        for i in range(tbl.n_rows):
            row = tbl.row(i)
            name, count, flux = row["NAME"], row["COUNT"], row["FLUX"]
            print(f"  {name!r:9} count={count!r:7} flux={flux}")

        print("FLUX column:", tbl.column("FLUX"))

The same in Rust:

//! Build, write and read back an ASCII table extension.
//!
//! This shows `AsciiTableBuilder` declaring fixed-width columns, the
//! `TNULL` sentinel that marks an undefined integer cell, and the
//! typed cells that come back on the read side.
//!
//! Run from the repository root:
//!
//!     cargo run --example ascii_table

use fitsy::hdu::builder::AsciiColumnData;
use fitsy::{AsciiCell, AsciiFormat, AsciiTableBuilder, FitsFile, FitsWriter, Hdu, ImageBuilder};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = std::env::temp_dir().join("fitsy_example_ascii_table.fits");

    // A file needs a primary HDU. An ASCII table is an extension, so
    // write an empty image first.
    let primary = ImageBuilder::new(Vec::<u64>::new(), Vec::<f32>::new())?
        .primary(true)
        .build()?;

    // Each column declares its own fixed field width through its
    // `TFORMn` code. `A8` holds eight characters, `I6` holds a
    // six-column integer, and `F9.3` holds a fixed-point real with
    // three decimal places.
    let mut b = AsciiTableBuilder::new();
    b.add_column(
        "NAME",
        AsciiFormat::A(8),
        AsciiColumnData::Str(vec![
            "alpha".to_string(),
            "beta".to_string(),
            "gamma".to_string(),
        ]),
    )?;
    b.add_column(
        "COUNT",
        AsciiFormat::I(6),
        // The middle cell is undefined, so this column needs a TNULL.
        AsciiColumnData::Int(vec![Some(12), None, Some(37)]),
    )?;
    // TNULL is the only way an ASCII table marks a value undefined:
    // Standard Sec.7.2.5 gives a blank numeric field the value zero.
    b.tnull("---")?;
    b.add_column(
        "FLUX",
        AsciiFormat::F(9, 3),
        AsciiColumnData::Float(vec![1.5, 2.25, 3.125]),
    )?;
    b.unit("Jy")?;
    b.extname("CATALOG");
    let table = b.build()?;

    let mut out = std::fs::File::create(&path)?;
    let mut w = FitsWriter::new(&mut out);
    w.write_hdu(&primary)?;
    w.write_hdu(&table)?;
    w.finish()?;

    // Read it back.
    let f = FitsFile::open(&path)?;
    let Hdu::AsciiTable(tbl) = f.hdu_by_name("CATALOG", None)? else {
        return Err("CATALOG is not an ASCII table".into());
    };

    println!(
        "rows: {}  row width: {} bytes",
        tbl.n_rows(),
        tbl.row_size()
    );
    for col in tbl.columns() {
        println!(
            "  col {} {:8} TFORM={:?} TBCOL={} unit={:?}",
            col.index, col.name, col.format, col.start, col.unit
        );
    }

    // `cell_value` returns `Ok(None)` for a cell that matches TNULL.
    let count = tbl.column_by_name("COUNT").ok_or("no COUNT column")?;
    for row in 0..tbl.n_rows() {
        match tbl.cell_value(row, count)? {
            Some(AsciiCell::Int(v)) => println!("  COUNT[{row}] = {v}"),
            Some(other) => println!("  COUNT[{row}] = {other:?}"),
            None => println!("  COUNT[{row}] = undefined (matched TNULL)"),
        }
    }

    std::fs::remove_file(&path)?;
    Ok(())
}