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) } } }