635 lines
22 KiB
Go
635 lines
22 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
package io
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/hex"
|
||
|
|
"math"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"slices"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/tensor/internal/core"
|
||
|
|
)
|
||
|
|
|
||
|
|
func ncTempPath(t *testing.T) string {
|
||
|
|
t.Helper()
|
||
|
|
return filepath.Join(t.TempDir(), "data.nc")
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFRoundTrip moves two variables of different dtypes through
|
||
|
|
// the file format and back, values, shapes, dimensions and attributes
|
||
|
|
// included.
|
||
|
|
func TestNetCDFRoundTrip(t *testing.T) {
|
||
|
|
dims := []NetCDFDim{{Name: "lat", Length: 3}, {Name: "lon", Length: 4}}
|
||
|
|
temp := 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)
|
||
|
|
// A scalar carries one value and no dimensions.
|
||
|
|
scalar, err := core.FromFloats([]float64{42}, 1)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FromFloats: %v", err)
|
||
|
|
}
|
||
|
|
vars := []NetCDFVar{
|
||
|
|
{
|
||
|
|
Name: "temp",
|
||
|
|
Dims: []string{"lat", "lon"},
|
||
|
|
Values: temp,
|
||
|
|
Attrs: map[string]string{"units": "degC", "long_name": "sea surface"},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "quality",
|
||
|
|
Values: scalar,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
path := ncTempPath(t)
|
||
|
|
attrs := map[string]string{"title": "round trip", "source": "tensor"}
|
||
|
|
if err := SaveNetCDF(path, dims, vars, attrs); err != nil {
|
||
|
|
t.Fatalf("SaveNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
gotDims, gotVars, gotAttrs, err := LoadNetCDF(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
if len(gotDims) != 2 || gotDims[0] != dims[0] || gotDims[1] != dims[1] {
|
||
|
|
t.Fatalf("dims = %v, want %v", gotDims, dims)
|
||
|
|
}
|
||
|
|
for k, want := range attrs {
|
||
|
|
if gotAttrs[k] != want {
|
||
|
|
t.Fatalf("global attr %q = %q, want %q", k, gotAttrs[k], want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(gotVars) != 2 {
|
||
|
|
t.Fatalf("got %d variables, want 2", len(gotVars))
|
||
|
|
}
|
||
|
|
tv := gotVars[0]
|
||
|
|
if tv.Name != "temp" || len(tv.Dims) != 2 || tv.Dims[0] != "lat" || tv.Dims[1] != "lon" {
|
||
|
|
t.Fatalf("temp dims = %v", tv.Dims)
|
||
|
|
}
|
||
|
|
if tv.Values.NDim() != 2 || tv.Values.Shape()[0] != 3 || tv.Values.Shape()[1] != 4 {
|
||
|
|
t.Fatalf("temp shape = %v", tv.Values.Shape())
|
||
|
|
}
|
||
|
|
for i := range temp.Len() {
|
||
|
|
if tv.Values.FloatAt(i) != temp.FloatAt(i) {
|
||
|
|
t.Fatalf("temp[%d] = %g, want %g", i, tv.Values.FloatAt(i), temp.FloatAt(i))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for k, want := range map[string]string{"units": "degC", "long_name": "sea surface"} {
|
||
|
|
if tv.Attrs[k] != want {
|
||
|
|
t.Fatalf("temp attr %q = %q, want %q", k, tv.Attrs[k], want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
qv := gotVars[1]
|
||
|
|
if qv.Name != "quality" || len(qv.Dims) != 0 || qv.Values.FloatAt(0) != 42 {
|
||
|
|
t.Fatalf("quality = %v %v %g", qv.Name, qv.Dims, qv.Values.FloatAt(0))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFIntAndFloat32Ranges pins the integer and single-precision
|
||
|
|
// external types, extremes included: NC_INT is a signed 32-bit value
|
||
|
|
// that lands int32 on read, and the float32 round trip is exact
|
||
|
|
// through the float64 landing NC_FLOAT keeps.
|
||
|
|
func TestNetCDFIntAndFloat32Ranges(t *testing.T) {
|
||
|
|
ints, err := core.FromInts([]int64{-2147483648, -1, 0, 1, 2147483647}, 5)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FromInts: %v", err)
|
||
|
|
}
|
||
|
|
f32, err := core.FromFloat32s([]float32{1.5, -2.25, 0, math.MaxFloat32, math.SmallestNonzeroFloat32}, 5)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FromFloat32s: %v", err)
|
||
|
|
}
|
||
|
|
path := ncTempPath(t)
|
||
|
|
vars := []NetCDFVar{
|
||
|
|
{Name: "i", Dims: []string{"n"}, Values: ints},
|
||
|
|
{Name: "f", Dims: []string{"n"}, Values: f32},
|
||
|
|
}
|
||
|
|
if err := SaveNetCDF(path, []NetCDFDim{{Name: "n", Length: 5}}, vars, nil); err != nil {
|
||
|
|
t.Fatalf("SaveNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
_, got, _, err := LoadNetCDF(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
if dt := got[0].Values.Dtype(); dt != core.Int32 {
|
||
|
|
t.Fatalf("int dtype = %s, want int32", dt)
|
||
|
|
}
|
||
|
|
wantInts := []int32{-2147483648, -1, 0, 1, 2147483647}
|
||
|
|
for i, want := range wantInts {
|
||
|
|
if got[0].Values.RawInt32s()[i] != want {
|
||
|
|
t.Fatalf("int[%d] = %d, want %d", i, got[0].Values.RawInt32s()[i], want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if dt := got[1].Values.Dtype(); dt != core.Float {
|
||
|
|
t.Fatalf("float32 dtype = %s, want the float64 landing NC_FLOAT keeps", dt)
|
||
|
|
}
|
||
|
|
for i := range 5 {
|
||
|
|
if got[1].Values.FloatAt(i) != float64(f32.RawFloat32s()[i]) {
|
||
|
|
t.Fatalf("f32[%d] = %g, want %g", i, got[1].Values.FloatAt(i), f32.RawFloat32s()[i])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFParsesKnownBytes pins the parser against the specification
|
||
|
|
// rather than against our own writer: this is the canonical tiny.nc
|
||
|
|
// from the NetCDF file format documentation, one SHORT variable "vx"
|
||
|
|
// of length 5 with values 3, 1, 4, 1, 5 and one fill-padded tail.
|
||
|
|
func TestNetCDFParsesKnownBytes(t *testing.T) {
|
||
|
|
const dump = "" +
|
||
|
|
"43444601" + // magic CDF\x01
|
||
|
|
"00000000" + // numrecs = 0
|
||
|
|
"0000000a" + // NC_DIMENSION
|
||
|
|
"00000001" + // one dimension
|
||
|
|
"0000000364696d00" + // name "dim" + pad
|
||
|
|
"00000005" + // length 5
|
||
|
|
"0000000000000000" + // gatt_list ABSENT
|
||
|
|
"0000000b" + // NC_VARIABLE
|
||
|
|
"00000001" + // one variable
|
||
|
|
"0000000276780000" + // name "vx" + pad
|
||
|
|
"00000001" + // rank 1
|
||
|
|
"00000000" + // dimid 0
|
||
|
|
"0000000000000000" + // vatt_list ABSENT
|
||
|
|
"00000003" + // NC_SHORT
|
||
|
|
"0000000c" + // vsize 12 (5 shorts + 1 fill pad)
|
||
|
|
"00000050" + // begin 80
|
||
|
|
"00030001000400010005" + // 3, 1, 4, 1, 5
|
||
|
|
"8001" // fill pad
|
||
|
|
raw, err := hex.DecodeString(dump)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("hex: %v", err)
|
||
|
|
}
|
||
|
|
if len(raw) != 92 {
|
||
|
|
t.Fatalf("fixture is %d bytes, the specification says 92", len(raw))
|
||
|
|
}
|
||
|
|
path := ncTempPath(t)
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
dims, vars, attrs, err := LoadNetCDF(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
if len(dims) != 1 || dims[0].Name != "dim" || dims[0].Length != 5 {
|
||
|
|
t.Fatalf("dims = %v", dims)
|
||
|
|
}
|
||
|
|
if len(vars) != 1 || vars[0].Name != "vx" || len(vars[0].Dims) != 1 || vars[0].Dims[0] != "dim" {
|
||
|
|
t.Fatalf("vars = %v", vars)
|
||
|
|
}
|
||
|
|
// NC_SHORT lands int16, the width the classic file stores.
|
||
|
|
if dt := vars[0].Values.Dtype(); dt != core.Int16 {
|
||
|
|
t.Fatalf("vx dtype = %s, want int16", dt)
|
||
|
|
}
|
||
|
|
if got := vars[0].Values.RawInt16s()[:5]; !slices.Equal(got, []int16{3, 1, 4, 1, 5}) {
|
||
|
|
t.Fatalf("vx = %v, want [3 1 4 1 5]", got)
|
||
|
|
}
|
||
|
|
want := []float64{3, 1, 4, 1, 5}
|
||
|
|
for i, w := range want {
|
||
|
|
if got := vars[0].Values.FloatAt(i); got != w {
|
||
|
|
t.Fatalf("vx[%d] = %g, want %g", i, got, w)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(attrs) != 0 {
|
||
|
|
t.Fatalf("attrs = %v, want none", attrs)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFErrors pins the refusal paths: a record dimension, an
|
||
|
|
// unknown version, a truncated file, bad names and a shape mismatch.
|
||
|
|
func TestNetCDFErrors(t *testing.T) {
|
||
|
|
path := ncTempPath(t)
|
||
|
|
a := mustFloats(t, []float64{1, 2, 3}, 3)
|
||
|
|
|
||
|
|
// The record dimension is supported, but only where the classic
|
||
|
|
// model allows it: leading the dimension list, leading each record
|
||
|
|
// variable's dimensions, and holding a whole number of records.
|
||
|
|
err := SaveNetCDF(path, []NetCDFDim{{Name: "x", Length: 2}, {Name: "t", Length: 0}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"x"}, Values: a}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "leads the list") {
|
||
|
|
t.Fatalf("record dimension not first: %v", err)
|
||
|
|
}
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "t", Length: 0}, {Name: "x", Length: 3}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"x", "t"}, Values: a}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "record axis leads") {
|
||
|
|
t.Fatalf("record axis not leading: %v", err)
|
||
|
|
}
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "t", Length: 0}, {Name: "x", Length: 2}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"t", "x"}, Values: a}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "whole number of records") {
|
||
|
|
t.Fatalf("partial record: %v", err)
|
||
|
|
}
|
||
|
|
two, terr := core.FromFloats([]float64{1, 2}, 2)
|
||
|
|
if terr != nil {
|
||
|
|
t.Fatalf("FromFloats: %v", terr)
|
||
|
|
}
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "t", Length: 0}, {Name: "x", Length: 1}},
|
||
|
|
[]NetCDFVar{
|
||
|
|
{Name: "u", Dims: []string{"t"}, Values: a},
|
||
|
|
{Name: "w", Dims: []string{"t", "x"}, Values: two},
|
||
|
|
}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "records, the others") {
|
||
|
|
t.Fatalf("record count disagreement: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// An int64 value outside int32 is refused, not truncated.
|
||
|
|
big, ferr := core.FromInts([]int64{1 << 40}, 1)
|
||
|
|
if ferr != nil {
|
||
|
|
t.Fatalf("FromInts: %v", ferr)
|
||
|
|
}
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "n", Length: 1}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"n"}, Values: big}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "int32 range") {
|
||
|
|
t.Fatalf("int64 out of range: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A landed narrow variable is refused by the writer, whose classic
|
||
|
|
// model stores float64, float32 and int64 arrays only.
|
||
|
|
i8, ierr := core.FromInt8s([]int8{1, 2, 3}, 3)
|
||
|
|
if ierr != nil {
|
||
|
|
t.Fatalf("FromInt8s: %v", ierr)
|
||
|
|
}
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "n", Length: 3}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"n"}, Values: i8}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "SaveNetCDF") ||
|
||
|
|
!strings.Contains(err.Error(), "int8") ||
|
||
|
|
!strings.Contains(err.Error(), "the classic model stores float64, float32 and int arrays") {
|
||
|
|
t.Fatalf("int8 variable: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The value count must match the dimensions.
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "n", Length: 4}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"n"}, Values: a}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "its dimensions hold 4") {
|
||
|
|
t.Fatalf("shape mismatch: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Names follow the traditional grammar.
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "bad name", Length: 1}},
|
||
|
|
[]NetCDFVar{{Name: "v", Dims: []string{"bad name"}, Values: mustFloats(t, []float64{1}, 1)}}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "forbids") {
|
||
|
|
t.Fatalf("bad name: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Duplicate variable names are refused.
|
||
|
|
err = SaveNetCDF(path, []NetCDFDim{{Name: "n", Length: 1}},
|
||
|
|
[]NetCDFVar{
|
||
|
|
{Name: "v", Dims: []string{"n"}, Values: mustFloats(t, []float64{1}, 1)},
|
||
|
|
{Name: "v", Dims: []string{"n"}, Values: mustFloats(t, []float64{2}, 1)},
|
||
|
|
}, nil)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "declared twice") {
|
||
|
|
t.Fatalf("duplicate variable: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Not a NetCDF file at all.
|
||
|
|
if err := os.WriteFile(path, []byte("not a netcdf file at all, sorry"), 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
if _, _, _, err := LoadNetCDF(path); err == nil || !strings.Contains(err.Error(), "not a NetCDF file") {
|
||
|
|
t.Fatalf("bad magic: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A version this module does not speak.
|
||
|
|
if err := os.WriteFile(path, []byte{'C', 'D', 'F', 9}, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
if _, _, _, err := LoadNetCDF(path); err == nil || !strings.Contains(err.Error(), "unsupported NetCDF version") {
|
||
|
|
t.Fatalf("bad version: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CDF-5, the 64-bit-count extension: refused by the same version
|
||
|
|
// gate rather than parsed as classic.
|
||
|
|
if err := os.WriteFile(path, []byte{'C', 'D', 'F', 5}, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
if _, _, _, err := LoadNetCDF(path); err == nil || !strings.Contains(err.Error(), "unsupported NetCDF version") {
|
||
|
|
t.Fatalf("CDF-5 version: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// A truncated header.
|
||
|
|
if err := os.WriteFile(path, []byte{'C', 'D', 'F', 1, 0, 0}, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
if _, _, _, err := LoadNetCDF(path); err == nil || !strings.Contains(err.Error(), "ends inside the header") {
|
||
|
|
t.Fatalf("truncated: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFRecordDimensionRead pins the record dimension at load
|
||
|
|
// time: a length of 0 in the leading dimension is the unlimited
|
||
|
|
// dimension, so the header parses and the dimension comes back with
|
||
|
|
// length 0 (its extent lives on each record variable's first axis).
|
||
|
|
func TestNetCDFRecordDimensionRead(t *testing.T) {
|
||
|
|
const dump = "" +
|
||
|
|
"43444601" + // magic
|
||
|
|
"00000001" + // numrecs = 1
|
||
|
|
"0000000a00000001" + // NC_DIMENSION, one dimension
|
||
|
|
"0000000174000000" + // name "t" + pad
|
||
|
|
"00000000" + // length 0: the record dimension
|
||
|
|
"0000000000000000" + // gatt ABSENT
|
||
|
|
"0000000b00000000" // NC_VARIABLE, zero variables
|
||
|
|
raw, err := hex.DecodeString(dump)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("hex: %v", err)
|
||
|
|
}
|
||
|
|
path := ncTempPath(t)
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
dims, vars, _, err := LoadNetCDF(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("record dimension: %v", err)
|
||
|
|
}
|
||
|
|
if len(dims) != 1 || dims[0].Name != "t" || dims[0].Length != 0 {
|
||
|
|
t.Fatalf("dims = %+v, want one record dimension named t with length 0", dims)
|
||
|
|
}
|
||
|
|
if len(vars) != 0 {
|
||
|
|
t.Fatalf("vars = %+v, want none", vars)
|
||
|
|
}
|
||
|
|
// A second zero-length dimension is refused: only one unlimited
|
||
|
|
// dimension exists, and it leads the list.
|
||
|
|
const twoRecords = "" +
|
||
|
|
"43444601" + // magic
|
||
|
|
"00000001" + // numrecs = 1
|
||
|
|
"0000000a00000002" + // NC_DIMENSION, two dimensions
|
||
|
|
"0000000174000000" + // name "t" + pad
|
||
|
|
"00000000" + // length 0: the record dimension
|
||
|
|
"0000000178000000" + // name "x" + pad
|
||
|
|
"00000000" + // length 0 again: refused
|
||
|
|
"0000000000000000" + // gatt ABSENT
|
||
|
|
"0000000b00000000" // NC_VARIABLE, zero variables
|
||
|
|
raw, err = hex.DecodeString(twoRecords)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("hex: %v", err)
|
||
|
|
}
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
if _, _, _, err := LoadNetCDF(path); err == nil {
|
||
|
|
t.Fatal("expected an error for a second zero-length dimension")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFClassicNativeLandings pins the classic type codes onto the
|
||
|
|
// core dtypes they land, against a hand-built CDF-1 file rather than
|
||
|
|
// the package's own writer: NC_BYTE as int8, NC_SHORT as int16,
|
||
|
|
// NC_INT as int32, NC_CHAR as uint8 raw bytes, and NC_FLOAT and
|
||
|
|
// NC_DOUBLE keeping their float64 landing. The values carry the
|
||
|
|
// extremes of every width, so a widened or unsigned reading of any of
|
||
|
|
// them fails the pin.
|
||
|
|
func TestNetCDFClassicNativeLandings(t *testing.T) {
|
||
|
|
const dump = "" +
|
||
|
|
"43444601" + // magic CDF\x01
|
||
|
|
"00000000" + // numrecs = 0
|
||
|
|
"0000000a00000001" + // NC_DIMENSION, one dimension
|
||
|
|
"000000016e000000" + // name "n" + pad
|
||
|
|
"00000004" + // length 4
|
||
|
|
"0000000000000000" + // gatt_list ABSENT
|
||
|
|
"0000000b00000006" + // NC_VARIABLE, six variables
|
||
|
|
"000000016200000000000001000000000000000000000000" + // "b": rank 1, dim 0, no attrs
|
||
|
|
"00000001" + // NC_BYTE
|
||
|
|
"00000004" + // vsize 4
|
||
|
|
"00000104" + // begin 260
|
||
|
|
"000000017300000000000001000000000000000000000000" + // "s"
|
||
|
|
"00000003" + // NC_SHORT
|
||
|
|
"00000008" +
|
||
|
|
"00000108" + // begin 264
|
||
|
|
"000000016900000000000001000000000000000000000000" + // "i"
|
||
|
|
"00000004" + // NC_INT
|
||
|
|
"00000010" +
|
||
|
|
"00000110" + // begin 272
|
||
|
|
"000000016300000000000001000000000000000000000000" + // "c"
|
||
|
|
"00000002" + // NC_CHAR
|
||
|
|
"00000004" +
|
||
|
|
"00000120" + // begin 288
|
||
|
|
"000000016600000000000001000000000000000000000000" + // "f"
|
||
|
|
"00000005" + // NC_FLOAT
|
||
|
|
"00000010" +
|
||
|
|
"00000124" + // begin 292
|
||
|
|
"000000016400000000000001000000000000000000000000" + // "d"
|
||
|
|
"00000006" + // NC_DOUBLE
|
||
|
|
"00000020" +
|
||
|
|
"00000134" + // begin 308
|
||
|
|
"80ff007f" + // b: -128, -1, 0, 127
|
||
|
|
"8000ffff00017fff" + // s: -32768, -1, 1, 32767
|
||
|
|
"80000000ffffffff000000017fffffff" + // i: int32 extremes
|
||
|
|
"0041c8ff" + // c: raw bytes 0, 65, 200, 255
|
||
|
|
"3fc00000c02000000000000040500000" + // f: 1.5, -2.5, 0, 3.25
|
||
|
|
"3ff8000000000000c004000000000000" + // d: 1.5, -2.5,
|
||
|
|
"00000000000000004010000000000000" // 0, 4
|
||
|
|
raw, err := hex.DecodeString(dump)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("hex: %v", err)
|
||
|
|
}
|
||
|
|
path := ncTempPath(t)
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
dims, vars, attrs, err := LoadNetCDF(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
if len(dims) != 1 || dims[0].Name != "n" || dims[0].Length != 4 {
|
||
|
|
t.Fatalf("dims = %v", dims)
|
||
|
|
}
|
||
|
|
if len(attrs) != 0 {
|
||
|
|
t.Fatalf("attrs = %v, want none", attrs)
|
||
|
|
}
|
||
|
|
if len(vars) != 6 {
|
||
|
|
t.Fatalf("vars = %d, want 6", len(vars))
|
||
|
|
}
|
||
|
|
byName := map[string]NetCDFVar{}
|
||
|
|
for _, v := range vars {
|
||
|
|
byName[v.Name] = v
|
||
|
|
if s := v.Values.Shape(); len(s) != 1 || s[0] != 4 {
|
||
|
|
t.Fatalf("%s shape = %v, want [4]", v.Name, s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if dt := byName["b"].Values.Dtype(); dt != core.Int8 {
|
||
|
|
t.Fatalf("NC_BYTE dtype = %s, want int8", dt)
|
||
|
|
}
|
||
|
|
if got, want := byName["b"].Values.RawInt8s()[:4], []int8{-128, -1, 0, 127}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("NC_BYTE values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
if dt := byName["s"].Values.Dtype(); dt != core.Int16 {
|
||
|
|
t.Fatalf("NC_SHORT dtype = %s, want int16", dt)
|
||
|
|
}
|
||
|
|
if got, want := byName["s"].Values.RawInt16s()[:4], []int16{-32768, -1, 1, 32767}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("NC_SHORT values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
if dt := byName["i"].Values.Dtype(); dt != core.Int32 {
|
||
|
|
t.Fatalf("NC_INT dtype = %s, want int32", dt)
|
||
|
|
}
|
||
|
|
if got, want := byName["i"].Values.RawInt32s()[:4], []int32{-2147483648, -1, 1, 2147483647}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("NC_INT values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
// CHAR carries bytes, not text: the array level lands uint8 and
|
||
|
|
// what the bytes spell is the caller's question.
|
||
|
|
if dt := byName["c"].Values.Dtype(); dt != core.Uint8 {
|
||
|
|
t.Fatalf("NC_CHAR dtype = %s, want uint8", dt)
|
||
|
|
}
|
||
|
|
if got, want := byName["c"].Values.RawUint8s()[:4], []uint8{0, 65, 200, 255}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("NC_CHAR values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
if dt := byName["f"].Values.Dtype(); dt != core.Float {
|
||
|
|
t.Fatalf("NC_FLOAT dtype = %s, want float", dt)
|
||
|
|
}
|
||
|
|
for i, want := range []float64{1.5, -2.5, 0, 3.25} {
|
||
|
|
if got := byName["f"].Values.FloatAt(i); got != want {
|
||
|
|
t.Fatalf("f[%d] = %v, want %v", i, got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if dt := byName["d"].Values.Dtype(); dt != core.Float {
|
||
|
|
t.Fatalf("NC_DOUBLE dtype = %s, want float", dt)
|
||
|
|
}
|
||
|
|
for i, want := range []float64{1.5, -2.5, 0, 4} {
|
||
|
|
if got := byName["d"].Values.FloatAt(i); got != want {
|
||
|
|
t.Fatalf("d[%d] = %v, want %v", i, got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFRecordNarrowLandings pins the record path on a hand-built
|
||
|
|
// CDF-1 file: an NC_BYTE record variable's slabs land int8 in place
|
||
|
|
// and an NC_SHORT record variable's slabs land int16, including the
|
||
|
|
// slab padding the classic layout writes between records, and a fixed
|
||
|
|
// NC_SHORT variable lands int16 beside them.
|
||
|
|
func TestNetCDFRecordNarrowLandings(t *testing.T) {
|
||
|
|
const dump = "" +
|
||
|
|
"43444601" + // magic
|
||
|
|
"00000002" + // numrecs = 2
|
||
|
|
"0000000a00000002" + // NC_DIMENSION, two dimensions
|
||
|
|
"0000000372656300" + // name "rec" + pad
|
||
|
|
"00000000" + // length 0: the record dimension
|
||
|
|
"0000000178000000" + // name "x" + pad
|
||
|
|
"00000003" + // length 3
|
||
|
|
"0000000000000000" + // gatt_list ABSENT
|
||
|
|
"0000000b00000003" + // NC_VARIABLE, three variables
|
||
|
|
"0000000272620000" + // name "rb" + pad
|
||
|
|
"00000001" + // rank 1
|
||
|
|
"00000000" + // dimid 0: the record dimension
|
||
|
|
"0000000000000000" + // vatt ABSENT
|
||
|
|
"00000001" + // NC_BYTE
|
||
|
|
"00000004" + // vsize 4: one byte padded to four
|
||
|
|
"000000ac" + // begin 172: its slab inside the first record
|
||
|
|
"0000000273730000" + // name "ss" + pad
|
||
|
|
"00000001" + // rank 1
|
||
|
|
"00000001" + // dimid 1: x, fixed
|
||
|
|
"0000000000000000" + // vatt ABSENT
|
||
|
|
"00000003" + // NC_SHORT
|
||
|
|
"00000008" + // vsize 8: six bytes padded to eight
|
||
|
|
"000000a4" + // begin 164
|
||
|
|
"0000000272730000" + // name "rs" + pad
|
||
|
|
"00000001" + // rank 1
|
||
|
|
"00000000" + // dimid 0: the record dimension
|
||
|
|
"0000000000000000" + // vatt ABSENT
|
||
|
|
"00000003" + // NC_SHORT
|
||
|
|
"00000004" + // vsize 4: one short padded to four
|
||
|
|
"000000b0" + // begin 176: its slab inside the first record
|
||
|
|
"0005fffa0007" + // ss: 5, -6, 7
|
||
|
|
"0000" + // slab padding
|
||
|
|
"07000000" + // rb record 0: 7
|
||
|
|
"12340000" + // rs record 0: 0x1234
|
||
|
|
"c8000000" + // rb record 1: 200 as int8 is -56
|
||
|
|
"fff00000" // rs record 1: -16
|
||
|
|
raw, err := hex.DecodeString(dump)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("hex: %v", err)
|
||
|
|
}
|
||
|
|
path := ncTempPath(t)
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
dims, vars, _, err := LoadNetCDF(path)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadNetCDF: %v", err)
|
||
|
|
}
|
||
|
|
if len(dims) != 2 || dims[0].Name != "rec" || dims[0].Length != 0 || dims[1].Length != 3 {
|
||
|
|
t.Fatalf("dims = %v", dims)
|
||
|
|
}
|
||
|
|
byName := map[string]NetCDFVar{}
|
||
|
|
for _, v := range vars {
|
||
|
|
byName[v.Name] = v
|
||
|
|
}
|
||
|
|
rb := byName["rb"].Values
|
||
|
|
if rb.Dtype() != core.Int8 {
|
||
|
|
t.Fatalf("rb dtype = %s, want int8", rb.Dtype())
|
||
|
|
}
|
||
|
|
if s := rb.Shape(); len(s) != 1 || s[0] != 2 {
|
||
|
|
t.Fatalf("rb shape = %v, want [2]: the record axis carries the record count", s)
|
||
|
|
}
|
||
|
|
if got, want := rb.RawInt8s()[:2], []int8{7, -56}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("rb values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
rs := byName["rs"].Values
|
||
|
|
if rs.Dtype() != core.Int16 {
|
||
|
|
t.Fatalf("rs dtype = %s, want int16", rs.Dtype())
|
||
|
|
}
|
||
|
|
if s := rs.Shape(); len(s) != 1 || s[0] != 2 {
|
||
|
|
t.Fatalf("rs shape = %v, want [2]: the record axis carries the record count", s)
|
||
|
|
}
|
||
|
|
if got, want := rs.RawInt16s()[:2], []int16{0x1234, -16}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("rs values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
ss := byName["ss"].Values
|
||
|
|
if ss.Dtype() != core.Int16 {
|
||
|
|
t.Fatalf("ss dtype = %s, want int16", ss.Dtype())
|
||
|
|
}
|
||
|
|
if got, want := ss.RawInt16s()[:3], []int16{5, -6, 7}; !slices.Equal(got, want) {
|
||
|
|
t.Fatalf("ss values = %v, want %v", got, want)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFUnknownTypeRefused pins the refusal of every type code
|
||
|
|
// beyond the classic six: the unsigned and 64-bit codes exist only in
|
||
|
|
// formats this module does not speak, and a header carrying one is
|
||
|
|
// refused by name rather than decoded into some other dtype.
|
||
|
|
func TestNetCDFUnknownTypeRefused(t *testing.T) {
|
||
|
|
const template = "" +
|
||
|
|
"43444601" + // magic
|
||
|
|
"00000000" + // numrecs = 0
|
||
|
|
"0000000000000000" + // dim_list ABSENT
|
||
|
|
"0000000000000000" + // gatt_list ABSENT
|
||
|
|
"0000000b" + // NC_VARIABLE
|
||
|
|
"00000001" + // one variable
|
||
|
|
"0000000176000000" + // name "v" + pad
|
||
|
|
"00000000" + // rank 0
|
||
|
|
"0000000000000000" + // vatt ABSENT
|
||
|
|
"00000007" + // type code, replaced per case
|
||
|
|
"00000000" + // vsize
|
||
|
|
"00000000" // begin
|
||
|
|
for _, tc := range []struct{ code, word string }{
|
||
|
|
{"7", "00000007"}, {"8", "00000008"}, {"9", "00000009"},
|
||
|
|
{"10", "0000000a"}, {"11", "0000000b"},
|
||
|
|
} {
|
||
|
|
raw, err := hex.DecodeString(strings.Replace(template, "00000007", tc.word, 1))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("hex: %v", err)
|
||
|
|
}
|
||
|
|
path := ncTempPath(t)
|
||
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||
|
|
t.Fatalf("write: %v", err)
|
||
|
|
}
|
||
|
|
_, _, _, err = LoadNetCDF(path)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "unknown type "+tc.code) {
|
||
|
|
t.Fatalf("type code %s: err = %v, want the unknown-type refusal", tc.code, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestNetCDFDecodeCellsRefused pins the explicit refusal inside
|
||
|
|
// decodeCells itself: an ncType without a case is a named error, never
|
||
|
|
// a buffer left silently untouched.
|
||
|
|
func TestNetCDFDecodeCellsRefused(t *testing.T) {
|
||
|
|
arr := core.New(core.Float, 2)
|
||
|
|
for _, code := range []uint32{0, 7, 8, 9, 10, 11, 12, 99} {
|
||
|
|
err := decodeCells(arr, make([]byte, 16), code, 0, 2)
|
||
|
|
if err == nil || !strings.Contains(err.Error(), "unknown type") {
|
||
|
|
t.Fatalf("decodeCells with type %d: err = %v, want the unknown-type refusal", code, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|