Files
tensor/io/fits_test.go
T

339 lines
11 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package io
import (
"encoding/binary"
"math"
"os"
"path/filepath"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"strings"
"testing"
)
func fitsTempPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "image.fits")
}
// padCard right-pads a card body with spaces to the full 80 bytes.
func padCard(text string) []byte {
return []byte(text + strings.Repeat(" ", 80-len(text)))
}
// TestFITSRoundTripFloat64 moves a rank-2 float64 image with negative
// and non-round values through the file format and back, values and
// header strings included.
func TestFITSRoundTripFloat64(t *testing.T) {
a := mustFloats(t, []float64{
1.5, -2.25, 3.125, 4,
-5.5, 6.75, -7.875, 8,
9.25, -10.5, 11.125, -12,
}, 3, 4)
path := fitsTempPath(t)
headers := map[string]string{
"OBJECT": "M31",
"OBSERVER": "petr's dome",
"EXPTIME": "600",
}
if err := SaveFITS(path, a, headers); err != nil {
t.Fatalf("SaveFITS: %v", err)
}
back, hdr, err := LoadFITS(path)
if err != nil {
t.Fatalf("LoadFITS: %v", err)
}
if back.Dtype() != core.Float || back.NDim() != 2 || back.Shape()[0] != 3 || back.Shape()[1] != 4 {
t.Fatalf("shape/dtype mismatch: %v %s", back.Shape(), back.Dtype())
}
for i := range a.Len() {
if back.FloatAt(i) != a.FloatAt(i) {
t.Fatalf("value[%d] = %g, want %g", i, back.FloatAt(i), a.FloatAt(i))
}
}
for key, want := range map[string]string{
"OBJECT": "M31",
"OBSERVER": "petr's dome",
"EXPTIME": "600",
} {
if hdr[key] != want {
t.Fatalf("header %q = %q, want %q", key, hdr[key], want)
}
}
}
// TestFITSAxisConvention pins the wire format against the raw bytes:
// NAXIS1 must carry the fastest (last Go) axis and the payload must
// be big-endian in flat row-major order.
func TestFITSAxisConvention(t *testing.T) {
a := mustFloats(t, []float64{1, 2, 3, 4, 5, 6}, 2, 3)
path := fitsTempPath(t)
if err := SaveFITS(path, a, nil); err != nil {
t.Fatalf("SaveFITS: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if len(raw)%2880 != 0 {
t.Fatalf("file length %d is not a multiple of 2880", len(raw))
}
header := raw[:2880]
for _, want := range []string{
"SIMPLE = T",
"BITPIX = -64",
"NAXIS = 2",
"NAXIS1 = 3",
"NAXIS2 = 2",
} {
if !strings.Contains(string(header), want) {
t.Fatalf("header misses %q", want)
}
}
endAt := strings.Index(string(header), "END")
if endAt < 0 {
t.Fatal("header has no END card")
}
var word [8]byte
for i := range 6 {
binary.BigEndian.PutUint64(word[:], math.Float64bits(float64(i+1)))
got := raw[2880+i*8 : 2880+i*8+8]
if string(got) != string(word[:]) {
t.Fatalf("payload word %d = % x, want % x", i, got, word)
}
}
}
// TestFITSRoundTripFloat32 keeps the float32 element type through the
// round trip, which is what BITPIX −32 stores.
func TestFITSRoundTripFloat32(t *testing.T) {
a := core.New(core.Float32, 5)
for i := range 5 {
a.RawFloat32s()[i] = float32(i) * 1.25
}
path := fitsTempPath(t)
if err := SaveFITS(path, a, nil); err != nil {
t.Fatalf("SaveFITS: %v", err)
}
back, _, err := LoadFITS(path)
if err != nil {
t.Fatalf("LoadFITS: %v", err)
}
if back.Dtype() != core.Float32 {
t.Fatalf("dtype = %s, want core.Float32", back.Dtype())
}
for i := range 5 {
if back.RawFloat32s()[i] != a.RawFloat32s()[i] {
t.Fatalf("value[%d] = %g, want %g", i, back.RawFloat32s()[i], a.RawFloat32s()[i])
}
}
}
// TestFITSSkipsValuelessCards checks COMMENT and HISTORY cards are
// tolerated and skipped rather than parsed as values.
func TestFITSSkipsValuelessCards(t *testing.T) {
a := mustFloats(t, []float64{1, 2, 3, 4}, 2, 2)
path := fitsTempPath(t)
if err := SaveFITS(path, a, map[string]string{"OBJECT": "TEST"}); err != nil {
t.Fatalf("SaveFITS: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
// Splice two valueless cards in before the END card and re-pad the
// header to the block boundary. The END card is matched in full
// ("END" plus its padding) because EXTEND contains the same three
// letters.
endCard := strings.Index(string(raw), "END"+strings.Repeat(" ", 77))
if endCard < 0 {
t.Fatal("no END card")
}
spliced := append([]byte{}, raw[:endCard]...)
spliced = append(spliced, padCard("COMMENT a note without a value")...)
spliced = append(spliced, padCard("HISTORY an audit trail entry")...)
spliced = append(spliced, padCard("END")...)
for len(spliced)%2880 != 0 {
spliced = append(spliced, padCard("")...)
}
spliced = append(spliced, raw[2880:]...)
if err := os.WriteFile(path, spliced, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
back, hdr, err := LoadFITS(path)
if err != nil {
t.Fatalf("LoadFITS: %v", err)
}
if back.Len() != 4 || back.FloatAt(3) != 4 {
t.Fatalf("payload damaged: %v %v", back.Shape(), back.RawFloats())
}
if hdr["OBJECT"] != "TEST" {
t.Fatalf("OBJECT = %q, want %q", hdr["OBJECT"], "TEST")
}
if _, ok := hdr["COMMENT"]; ok {
t.Fatal("COMMENT card must not enter the header map")
}
}
// TestFITSErrors covers every refusal path in the format layer.
func TestFITSErrors(t *testing.T) {
path := fitsTempPath(t)
ints, ierr := core.FromInts([]int64{1, 2, 3, 4}, 2, 2)
if ierr != nil {
t.Fatalf("FromInts: %v", ierr)
}
if err := SaveFITS(path, ints, nil); err == nil {
t.Fatal("int64 input: want an error")
}
if err := SaveFITS(path, mustComplexes(t, []complex128{1, 2, 3, 4}, 2, 2), nil); err == nil {
t.Fatal("complex input: want an error")
}
ok := mustFloats(t, []float64{1, 2}, 2)
if err := SaveFITS(path, ok, map[string]string{"TOOLONGKEYWORD": "x"}); err == nil {
t.Fatal("long keyword: want an error")
}
if err := SaveFITS(path, ok, map[string]string{"BITPIX": "x"}); err == nil {
t.Fatal("reserved keyword: want an error")
}
if err := SaveFITS(path, ok, map[string]string{"naxis2": "x"}); err == nil {
t.Fatal("NAXISn keyword: want an error")
}
if err := SaveFITS(path, ok, map[string]string{"BAD KEY": "x"}); err == nil {
t.Fatal("space in keyword: want an error")
}
if err := SaveFITS(path, ok, map[string]string{"NOTE": strings.Repeat("x", 69)}); err == nil {
t.Fatal("overlong value: want an error")
}
// A valid file to damage in every way the parser must catch.
if err := SaveFITS(path, ok, nil); err != nil {
t.Fatalf("SaveFITS: %v", err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
with := func(mutate func([]byte) []byte) {
t.Helper()
if err := os.WriteFile(path, mutate(append([]byte{}, raw...)), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, _, err := LoadFITS(path); err == nil {
t.Fatal("damaged file: want an error")
}
}
with(func(b []byte) []byte { return b[:100] }) // no END card
with(func(b []byte) []byte { return b[:2880] }) // data truncated
with(func(b []byte) []byte { b[30] = 'F'; return b }) // SIMPLE = F
with(func(b []byte) []byte { copy(b[11:20], "XTENSION"); return b }) // wrong first card
// BITPIX 8 (unsigned bytes) is outside the supported image types.
unsupported := append(append(append(append([]byte{},
padCard("SIMPLE = T")...),
padCard("BITPIX = 8")...),
padCard("NAXIS = 1")...),
padCard("NAXIS1 = 4")...)
unsupported = append(unsupported, padCard("END")...)
unsupported = append(unsupported, make([]byte, 2880-5*80)...)
unsupported = append(unsupported, make([]byte, 2880)...)
if err := os.WriteFile(path, unsupported, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, _, err := LoadFITS(path); err == nil {
t.Fatal("BITPIX 8: want an error")
}
// A hostile header whose axis product overflows int must be
// refused as truncated data, never panic the process.
hostile := append(append(append(append([]byte{},
padCard("SIMPLE = T")...),
padCard("BITPIX = -64")...),
padCard("NAXIS = 2")...),
padCard("NAXIS1 = 1099511627776")...)
hostile = append(hostile, padCard("NAXIS2 = 1099511627776")...)
hostile = append(hostile, padCard("END")...)
hostile = append(hostile, make([]byte, 2880-6*80)...)
hostile = append(hostile, make([]byte, 2880)...)
if err := os.WriteFile(path, hostile, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, _, err := LoadFITS(path); err == nil {
t.Fatal("overflowing axis product: want an error")
}
// An XTENSION-first file is an extension, not a primary image.
ext := append([]byte{}, padCard("XTENSION= 'IMAGE '")...)
ext = append(ext, raw[80:]...)
if err := os.WriteFile(path, ext, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, _, err := LoadFITS(path); err == nil {
t.Fatal("extension header: want an error")
}
// The intact file still loads after all the damage around it.
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if _, _, err := LoadFITS(path); err != nil {
t.Fatalf("intact file: %v", err)
}
}
// TestCommentaryCardsAreNotValueCards pins the commentary rule: a
// COMMENT or HISTORY card may legitimately carry an "= " sequence in
// columns 9-10, and such a card must be skipped as commentary, not
// parsed as a keyword with a value.
func TestCommentaryCardsAreNotValueCards(t *testing.T) {
cards := []string{
fitsBoolCard("SIMPLE", true),
fitsIntCard("BITPIX", -64),
fitsIntCard("NAXIS", 1),
fitsIntCard("NAXIS1", 2),
fitsPadCard("COMMENT = this looks like a value card"),
fitsPadCard("HISTORY = so does this one"),
fitsStringCardRaw("OBSERVER", "tester"),
fitsEndCard(),
}
data := fitsAppendCards(nil, cards)
payload := []byte{0x3f, 0xf0, 0, 0, 0, 0, 0, 0, 0x40, 0, 0, 0, 0, 0, 0, 0} // 1.0, 2.0
data = append(data, payload...)
data = fitsAppendZeroPad(data)
path := filepath.Join(t.TempDir(), "commentary.fits")
if err := osWriteFile(path, data); err != nil {
t.Fatalf("os.WriteFile: %v", err)
}
img, headers, err := LoadFITS(path)
if err != nil {
t.Fatalf("LoadFITS: %v", err)
}
if img.Len() != 2 || img.FloatAt(0) != 1 || img.FloatAt(1) != 2 {
t.Fatalf("image = %s, want [1, 2]", img)
}
if _, ok := headers["COMMENT"]; ok {
t.Error("COMMENT was parsed as a value keyword")
}
if _, ok := headers["HISTORY"]; ok {
t.Error("HISTORY was parsed as a value keyword")
}
if headers["OBSERVER"] != "tester" {
t.Errorf("OBSERVER = %q, want tester", headers["OBSERVER"])
}
}
// TestFitsCheckKeywordRefusesCommentary pins that COMMENT and HISTORY
// are refused as user keywords: they carry no value in the format.
func TestFitsCheckKeywordRefusesCommentary(t *testing.T) {
dir := t.TempDir()
img := mustFloats(t, []float64{1, 2}, 2)
for _, kw := range []string{"COMMENT", "HISTORY"} {
if err := SaveFITS(filepath.Join(dir, strings.ToLower(kw)+".fits"), img, map[string]string{kw: "x"}); err == nil {
t.Errorf("SaveFITS accepted the reserved keyword %q", kw)
}
}
}