207 lines
6.5 KiB
Go
207 lines
6.5 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package io
|
||
|
||
import (
|
||
"encoding/binary"
|
||
"fmt"
|
||
"math"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||
)
|
||
|
||
// Regression pins for the FITS table codecs: the binary and ASCII
|
||
// column round trips, the header cards that size them, and the hostile
|
||
// counts and widths the reader must refuse by name.
|
||
|
||
// card renders one 80-byte FITS header card from a full-line body.
|
||
func card(body string) []byte {
|
||
return []byte(body + strings.Repeat(" ", 80-len(body)))
|
||
}
|
||
|
||
// cardBlock concatenates cards and pads to the FITS block size.
|
||
func cardBlock(cards ...[]byte) []byte {
|
||
var b []byte
|
||
for _, c := range cards {
|
||
b = append(b, c...)
|
||
}
|
||
if rem := len(b) % 2880; rem != 0 {
|
||
b = append(b, make([]byte, 2880-rem)...)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// TestBinaryStringColumnRoundTrip pins the nA binary column contract:
|
||
// the reader must accept the repeat-count width the writer emits, so
|
||
// SaveFITSTable to LoadFITSTable survives character columns.
|
||
func TestBinaryStringColumnRoundTrip(t *testing.T) {
|
||
names := []string{"M31", "NGC 1275", "SMC"}
|
||
cols := []FITSTableColumn{
|
||
{Name: "NAME", Form: "8A", Text: names},
|
||
{Name: "FLUX", Form: "D", Data: mustFloats(t, []float64{1, 2, 3}, 3)},
|
||
}
|
||
path := filepath.Join(t.TempDir(), "names.fits")
|
||
if err := SaveFITSTable(path, false, cols, nil); err != nil {
|
||
t.Fatalf("SaveFITSTable: %v", err)
|
||
}
|
||
table, err := LoadFITSTable(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadFITSTable: %v", err)
|
||
}
|
||
for i, want := range names {
|
||
if table.Text[0][i] != want {
|
||
t.Fatalf("NAME[%d] = %q, want %q", i, table.Text[0][i], want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTableTSCALTZERO pins the scaling keywords: a binary int column
|
||
// with TSCAL/TZERO must read back through the affine map, the standard
|
||
// unsigned-integer convention included.
|
||
func TestTableTSCALTZERO(t *testing.T) {
|
||
raw := []int64{1, 2, 3}
|
||
ints := core.New(core.Int, len(raw))
|
||
copy(ints.RawInts(), raw)
|
||
cols := []FITSTableColumn{
|
||
{Name: "U16", Form: "K", Data: ints},
|
||
}
|
||
path := filepath.Join(t.TempDir(), "scaled.fits")
|
||
headers := map[string]string{"TSCAL1": "1", "TZERO1": "32768"}
|
||
if err := SaveFITSTable(path, false, cols, headers); err != nil {
|
||
t.Fatalf("SaveFITSTable: %v", err)
|
||
}
|
||
table, err := LoadFITSTable(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadFITSTable: %v", err)
|
||
}
|
||
col := table.Columns[0]
|
||
for i, v := range raw {
|
||
want := float64(v) + 32768
|
||
got := col.FloatAt(i)
|
||
if got != want {
|
||
t.Fatalf("U16[%d] = %g, want %g", i, got, want)
|
||
}
|
||
}
|
||
// A fractional scale promotes the column to float.
|
||
if err := SaveFITSTable(path, false, cols, map[string]string{"TSCAL1": "0.5", "TZERO1": "0"}); err != nil {
|
||
t.Fatalf("SaveFITSTable: %v", err)
|
||
}
|
||
table, err = LoadFITSTable(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadFITSTable: %v", err)
|
||
}
|
||
if table.Columns[0].Dtype() != core.Float {
|
||
t.Fatalf("scaled int column dtype %s, want float", table.Columns[0].Dtype())
|
||
}
|
||
}
|
||
|
||
// TestLoadFITSTableSkips16BitImage pins the image-HDU skip arithmetic
|
||
// on the case that used to break: a positive BITPIX (16) with a
|
||
// non-square shape, where |BITPIX|/8 · Π NAXISn ≠ Σ NAXISn.
|
||
func TestLoadFITSTableSkips16BitImage(t *testing.T) {
|
||
// A 5×3 16-bit image (NAXIS1 = 5 is the fastest axis): 15 pixels,
|
||
// 30 bytes of data.
|
||
hdr := cardBlock(
|
||
card("SIMPLE = T"),
|
||
card("BITPIX = 16"),
|
||
card("NAXIS = 2"),
|
||
card("NAXIS1 = 5"),
|
||
card("NAXIS2 = 3"),
|
||
card("END"),
|
||
)
|
||
data := make([]byte, 0, 30)
|
||
for range 15 {
|
||
data = binary.BigEndian.AppendUint16(data, 42)
|
||
}
|
||
image := append(hdr, data...)
|
||
if rem := len(image) % 2880; rem != 0 {
|
||
image = append(image, make([]byte, 2880-rem)...)
|
||
}
|
||
|
||
cols := []FITSTableColumn{
|
||
{Name: "V", Form: "K", Data: mustInts(t, []int64{7, 8}, 2)},
|
||
}
|
||
tablePath := filepath.Join(t.TempDir(), "table.fits")
|
||
if err := SaveFITSTable(tablePath, false, cols, nil); err != nil {
|
||
t.Fatalf("SaveFITSTable: %v", err)
|
||
}
|
||
tableBytes, err := osReadFile(tablePath)
|
||
if err != nil {
|
||
t.Fatalf("read table: %v", err)
|
||
}
|
||
// Drop the table file's empty primary HDU (first block), keep the
|
||
// extension behind the hand-built image.
|
||
path := filepath.Join(t.TempDir(), "im_then_table.fits")
|
||
if err := osWriteFile(path, image, tableBytes[2880:]); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
table, err := LoadFITSTable(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadFITSTable after a 16-bit image: %v", err)
|
||
}
|
||
if table.Rows != 2 || table.Columns[0].RawInts()[1] != 8 {
|
||
t.Fatalf("table landed wrong: rows %d, V[1] = %d", table.Rows, table.Columns[0].RawInts()[1])
|
||
}
|
||
}
|
||
|
||
// TestLoadFITSBSCALE pins image scaling: physical = raw·BSCALE + BZERO.
|
||
func TestLoadFITSBSCALE(t *testing.T) {
|
||
hdr := cardBlock(
|
||
card("SIMPLE = T"),
|
||
card("BITPIX = -64"),
|
||
card("NAXIS = 1"),
|
||
card("NAXIS1 = 2"),
|
||
card("BSCALE = 2.0"),
|
||
card("BZERO = 10.0"),
|
||
card("END"),
|
||
)
|
||
var data []byte
|
||
data = binary.BigEndian.AppendUint64(data, math.Float64bits(1))
|
||
data = binary.BigEndian.AppendUint64(data, math.Float64bits(2))
|
||
path := filepath.Join(t.TempDir(), "scaled.fits")
|
||
var payload []byte
|
||
payload = append(payload, data...)
|
||
if rem := len(payload) % 2880; rem != 0 {
|
||
payload = append(payload, make([]byte, 2880-rem)...)
|
||
}
|
||
if err := osWriteFile(path, hdr, payload); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
img, _, err := LoadFITS(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadFITS: %v", err)
|
||
}
|
||
if img.FloatAt(0) != 12 || img.FloatAt(1) != 14 {
|
||
t.Fatalf("scaled pixels (%g, %g), want (12, 14)", img.FloatAt(0), img.FloatAt(1))
|
||
}
|
||
}
|
||
|
||
// TestFITSContinueLongString pins the long-string convention: a value
|
||
// ending in '&' continues on the CONTINUE cards instead of truncating.
|
||
func TestFITSContinueLongString(t *testing.T) {
|
||
hdr := cardBlock(
|
||
card("SIMPLE = T"),
|
||
card("BITPIX = 8"),
|
||
card("NAXIS = 0"),
|
||
card(fmt.Sprintf("%-8s= '%s&'", "LONGKEY", "the first part of a long value ")),
|
||
card(fmt.Sprintf("CONTINUE '%s'", "and the second part")),
|
||
card("END"),
|
||
)
|
||
path := filepath.Join(t.TempDir(), "long.fits")
|
||
if err := osWriteFile(path, hdr); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
_, headers, err := LoadFITS(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadFITS: %v", err)
|
||
}
|
||
want := "the first part of a long value and the second part"
|
||
if headers["LONGKEY"] != want {
|
||
t.Fatalf("LONGKEY = %q, want %q", headers["LONGKEY"], want)
|
||
}
|
||
}
|