fix(input): correct lon mapping

This commit is contained in:
gili8420 2026-08-04 13:40:50 +09:00
parent 19557f3a62
commit 84d1664b29
20 changed files with 1197 additions and 137 deletions

View file

@ -68,9 +68,20 @@ const (
// Rasterize samples field over req and returns the U/V grid payload.
//
// Data is laid out in wind-js scan order: row 0 is the northernmost
// latitude (la1), each row runs west→east, longitudes increasing. Per-cell
// sampling errors (e.g. altitude outside the model) are written as 0 rather
// than failing the whole request; a time outside coverage is a hard error.
// latitude (la1), each row runs west→east, longitudes increasing.
//
// Cells that cannot be sampled individually — a regional dataset queried outside
// its region — are written as 0 and the request still succeeds, because a partly
// covered grid is worth drawing. A grid in which nothing could be sampled is an
// error instead. Time is one value for the whole request, so a time past the
// dataset's horizon fails every cell, and returning that as zeros made a velocity
// layer draw an atmosphere at perfect rest where the honest answer is "we have no
// data for this time".
//
// Known gap: an individual zero-filled cell is indistinguishable from genuine
// calm in the payload. The wind-js format expresses missing data as null, which
// would mean typing Data as []*float64; there is no consumer to validate that
// against since the browser wind layer was removed in the Cesium migration.
func Rasterize(field weather.WindField, req Request) (Field, error) {
step := req.Step
if step <= 0 {
@ -125,6 +136,8 @@ func Rasterize(field weather.WindField, req Request) (Field, error) {
v := make([]float64, nx*ny)
// Row 0 = north (la1); rows descend in latitude.
var failed int
var firstErr error
for j := range ny {
lat := maxLat - float64(j)*step
for i := range nx {
@ -132,12 +145,20 @@ func Rasterize(field weather.WindField, req Request) (Field, error) {
s, err := field.Wind(req.Time, lat, normLng(lng), req.Altitude)
idx := j*nx + i
if err != nil {
continue // leave as 0
failed++
if firstErr == nil {
firstErr = err
}
continue // leave as 0; see the doc comment on this distinction
}
u[idx] = s.U
v[idx] = s.V
}
}
if failed == nx*ny {
return nil, fmt.Errorf("no wind data anywhere in the requested grid at %s: %w",
time.Unix(int64(req.Time), 0).UTC().Format(time.RFC3339), firstErr)
}
refTime := time.Unix(int64(req.Time), 0).UTC().Format("2006-01-02T15:04:05.000Z")
mk := func(num int, name string, data []float64) Component {

View file

@ -1,6 +1,8 @@
package windviz
import (
"fmt"
"strings"
"testing"
"time"
@ -94,3 +96,86 @@ func TestCacheRoundTrip(t *testing.T) {
t.Errorf("cache should hit after put")
}
}
// horizonWind has data up to horizon seconds and fails past it, the way a real
// dataset's time axis does.
type horizonWind struct{ horizon float64 }
func (w horizonWind) Wind(t, _, _, _ float64) (weather.Sample, error) {
if t > w.horizon {
return weather.Sample{}, fmt.Errorf("hour=%v out of range", t/3600)
}
return weather.Sample{U: 7, V: -3}, nil
}
func (w horizonWind) Epoch() time.Time { return time.Unix(0, 0).UTC() }
func (w horizonWind) Source() string { return "test" }
// northOnlyWind has data only in the northern hemisphere, the way a regional
// subset does. Its failures are genuinely per-cell.
type northOnlyWind struct{}
func (northOnlyWind) Wind(_, lat, _, _ float64) (weather.Sample, error) {
if lat < 0 {
return weather.Sample{}, fmt.Errorf("lat=%v outside region", lat)
}
return weather.Sample{U: 5, V: 1}, nil
}
func (northOnlyWind) Epoch() time.Time { return time.Unix(0, 0).UTC() }
func (northOnlyWind) Source() string { return "test" }
// TestRasterizeRefusesWhenNoCellHasData is the case the package comment already
// promised and the code never implemented ("a time outside coverage is a hard
// error").
//
// Time is one value for the whole request, so a time past the dataset's horizon
// fails every cell. Each failure was written as a zero, so the response was a
// complete grid of zero wind — a velocity layer draws that as an atmosphere at
// perfect rest. Missing data must not be rendered as calm air.
func TestRasterizeRefusesWhenNoCellHasData(t *testing.T) {
f := horizonWind{horizon: 600}
out, err := Rasterize(f, Request{Time: 700, MinLng: 0, MaxLng: 360, Step: 90})
if err == nil {
t.Fatalf("Rasterize returned %d components instead of an error", len(out))
}
if !strings.Contains(err.Error(), "out of range") {
t.Errorf("error %q does not carry the sampler's reason", err)
}
}
// TestRasterizeStillSucceedsWithinCoverage keeps the check above from rejecting
// healthy requests.
func TestRasterizeStillSucceedsWithinCoverage(t *testing.T) {
f := horizonWind{horizon: 600}
out, err := Rasterize(f, Request{Time: 300, MinLng: 0, MaxLng: 360, Step: 90})
if err != nil {
t.Fatalf("Rasterize within coverage: %v", err)
}
if got := out[0].Data[0]; got != 7 {
t.Errorf("u = %v, want 7", got)
}
}
// TestRasterizeZeroFillsIndividualGaps preserves the documented per-cell
// behaviour: a partly-covered grid is still worth drawing, so gaps stay zero and
// the request succeeds. Only a grid with nothing in it is refused.
func TestRasterizeZeroFillsIndividualGaps(t *testing.T) {
out, err := Rasterize(northOnlyWind{}, Request{
MinLat: -30, MaxLat: 30, MinLng: 0, MaxLng: 30, Step: 30,
})
if err != nil {
t.Fatalf("Rasterize partly-covered grid: %v", err)
}
// Row 0 is the northernmost latitude (+30), the last row is -30.
nx := out[0].Header.Nx
ny := out[0].Header.Ny
if got := out[0].Data[0]; got != 5 {
t.Errorf("northern cell u = %v, want 5", got)
}
if got := out[0].Data[(ny-1)*nx]; got != 0 {
t.Errorf("southern gap u = %v, want 0", got)
}
}