Files
tensor/io/fits_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

339 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}
}