feat(decart): interp

This commit is contained in:
gili8420 2026-08-03 22:10:10 +09:00
parent 1bd9143186
commit 19557f3a62
14 changed files with 681 additions and 208 deletions

View file

@ -23,9 +23,11 @@ func TestAxisLocate(t *testing.T) {
t.Errorf("Locate(-89.75) = %+v, %v; want frac=0.5", b, err)
}
// 90 is exactly on the upper boundary — there's no Hi above it
if _, err := a.Locate(90); err == nil {
t.Errorf("Locate(90) should error, got nil")
// 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 {
@ -47,9 +49,16 @@ func TestAxisLocateWrap(t *testing.T) {
t.Errorf("Locate(359.75) = %+v, %v; want {719 0 0.5}", b, err)
}
// 360 is outside the half-open interval
if _, err := a.Locate(360); err == nil {
t.Errorf("Locate(360) should error, got nil")
// 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")
}
}
@ -92,3 +101,56 @@ func TestLerp(t *testing.T) {
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)
}
}
}