predictor/internal/numerics/grid_test.go
2026-08-03 22:11:10 +09:00

156 lines
5.3 KiB
Go

package numerics
import (
"math"
"testing"
)
func TestAxisLocate(t *testing.T) {
a := Axis{Left: -90, Step: 0.5, N: 361, Name: "lat"}
b, err := a.Locate(-90)
if err != nil || b.Lo != 0 || b.Hi != 1 || b.Frac != 0 {
t.Errorf("Locate(-90) = %+v, %v; want {0 1 0}, nil", b, err)
}
b, err = a.Locate(0)
if err != nil || b.Lo != 180 || b.Hi != 181 || b.Frac != 0 {
t.Errorf("Locate(0) = %+v, %v; want {180 181 0}, nil", b, err)
}
b, err = a.Locate(-89.75)
if err != nil || b.Lo != 0 || b.Hi != 1 || math.Abs(b.Frac-0.5) > 1e-12 {
t.Errorf("Locate(-89.75) = %+v, %v; want frac=0.5", b, err)
}
// 90 is exactly on the upper boundary. It is now accepted as the far edge of
// the last cell: on the GFS latitude axis that is the north pole, whose row
// carries real data. Rejecting it used to freeze predictions there.
if b, err := a.Locate(90); err != nil || b.Lo != 359 || b.Hi != 360 || math.Abs(b.Frac-1) > 1e-12 {
t.Errorf("Locate(90) = %+v, %v; want {359 360 1}", b, err)
}
if _, err := a.Locate(-91); err == nil {
t.Errorf("Locate(-91) should error, got nil")
}
}
func TestAxisLocateWrap(t *testing.T) {
a := Axis{Left: 0, Step: 0.5, N: 720, Wrap: true, Name: "lng"}
b, err := a.Locate(0)
if err != nil || b.Lo != 0 || b.Hi != 1 || b.Frac != 0 {
t.Errorf("Locate(0) = %+v, %v", b, err)
}
// Right up against the wrap boundary
b, err = a.Locate(359.75)
if err != nil || b.Lo != 719 || b.Hi != 0 || math.Abs(b.Frac-0.5) > 1e-12 {
t.Errorf("Locate(359.75) = %+v, %v; want {719 0 0.5}", b, err)
}
// 360 is the wrap point and now resolves to it: the far edge of the last
// cell, whose Hi is index 0. Weight 1 there means exactly 0 degrees, which
// is what 360 means. Callers normalise longitude anyway, so this is a
// consistency property rather than a path anyone relies on.
if b, err := a.Locate(360); err != nil || b.Lo != 719 || b.Hi != 0 || math.Abs(b.Frac-1) > 1e-12 {
t.Errorf("Locate(360) = %+v, %v; want {719 0 1}", b, err)
}
if _, err := a.Locate(360.5); err == nil {
t.Errorf("Locate(360.5) should error, got nil")
}
}
func TestEvalTrilinear(t *testing.T) {
// Field f(i,j,k) = 100*i + 10*j + k.
f := func(i, j, k int) float64 { return 100*float64(i) + 10*float64(j) + float64(k) }
// At all fractions = 0.5, expected value is the mean of the 8 corners.
bs := [3]Bracket{{Lo: 0, Hi: 1, Frac: 0.5}, {Lo: 0, Hi: 1, Frac: 0.5}, {Lo: 0, Hi: 1, Frac: 0.5}}
got := EvalTrilinear(bs, f)
want := (0 + 1 + 10 + 11 + 100 + 101 + 110 + 111) / 8.0
if math.Abs(got-want) > 1e-12 {
t.Errorf("EvalTrilinear at center = %v, want %v", got, want)
}
// At all fractions = 0, expected value is f(lo, lo, lo) = 0.
bs = [3]Bracket{{Lo: 0, Hi: 1, Frac: 0}, {Lo: 0, Hi: 1, Frac: 0}, {Lo: 0, Hi: 1, Frac: 0}}
got = EvalTrilinear(bs, f)
if got != 0 {
t.Errorf("EvalTrilinear at (lo,lo,lo) = %v, want 0", got)
}
// Asymmetric: linear field f(i,j,k) = i should give frac of axis 0 exactly.
f2 := func(i, _, _ int) float64 { return float64(i) }
bs = [3]Bracket{{Lo: 0, Hi: 1, Frac: 0.3}, {Lo: 0, Hi: 1, Frac: 0.7}, {Lo: 0, Hi: 1, Frac: 0.9}}
got = EvalTrilinear(bs, f2)
if math.Abs(got-0.3) > 1e-12 {
t.Errorf("EvalTrilinear of i-field = %v, want 0.3", got)
}
}
func TestLerp(t *testing.T) {
if Lerp(10, 20, 0) != 10 {
t.Errorf("Lerp(10, 20, 0) != 10")
}
if Lerp(10, 20, 1) != 20 {
t.Errorf("Lerp(10, 20, 1) != 20")
}
if math.Abs(Lerp(10, 20, 0.25)-12.5) > 1e-12 {
t.Errorf("Lerp(10, 20, 0.25) != 12.5")
}
}
// The GFS latitude axis runs -90..90 at 0.5 deg (N=361), so the north pole is
// the axis's exact upper bound. Bracketing must accept it: the pole row holds
// real data (NCEP resolves it per longitude via POLFIXV), and refusing it made
// the whole prediction freeze silently at latitude 90. The same applies to the
// last forecast hour and the topmost pressure level.
func TestAxisLocateAcceptsExactUpperBound(t *testing.T) {
t.Parallel()
lat := Axis{Left: -90, Step: 0.5, N: 361, Name: "lat"}
tests := []struct {
name string
value float64
wantLo int
wantHi int
wantFrac float64
}{
{name: "exact lower bound", value: -90, wantLo: 0, wantHi: 1, wantFrac: 0},
{name: "interior point", value: 0.25, wantLo: 180, wantHi: 181, wantFrac: 0.5},
{name: "one cell below the top", value: 89.5, wantLo: 359, wantHi: 360, wantFrac: 0},
{name: "inside the top cell", value: 89.75, wantLo: 359, wantHi: 360, wantFrac: 0.5},
// The case that used to error: the far edge of the last cell.
{name: "exact upper bound is the top of the last cell", value: 90, wantLo: 359, wantHi: 360, wantFrac: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b, err := lat.Locate(tt.value)
if err != nil {
t.Fatalf("Locate(%v) returned error: %v", tt.value, err)
}
if b.Lo != tt.wantLo || b.Hi != tt.wantHi {
t.Errorf("Locate(%v) = lo %d hi %d, want lo %d hi %d", tt.value, b.Lo, b.Hi, tt.wantLo, tt.wantHi)
}
if math.Abs(b.Frac-tt.wantFrac) > 1e-12 {
t.Errorf("Locate(%v) frac = %v, want %v", tt.value, b.Frac, tt.wantFrac)
}
})
}
}
func TestAxisLocateStillRejectsOutOfRange(t *testing.T) {
t.Parallel()
lat := Axis{Left: -90, Step: 0.5, N: 361, Name: "lat"}
for _, v := range []float64{-90.001, 90.001, 91, -100} {
if _, err := lat.Locate(v); err == nil {
t.Errorf("Locate(%v) accepted a value outside the axis", v)
}
}
}