feat: polish & windviz & deploy

This commit is contained in:
Anatoly Antonov 2026-05-30 06:29:39 +09:00
parent 81b8e763bd
commit 465ad00f7b
78 changed files with 20622 additions and 2154 deletions

63
internal/windviz/cache.go Normal file
View file

@ -0,0 +1,63 @@
package windviz
import (
"sync"
"time"
)
// Cache is a small bounded cache of rasterized fields keyed by request
// parameters and dataset epoch. It is safe for concurrent use.
//
// Visualization requests repeat heavily (a frontend re-fetches the same
// layer as users pan within a tile), so even a tiny cache removes most
// recomputation. Eviction is simplest-possible: when full, the whole map is
// cleared. Entries also expire after TTL.
type Cache struct {
mu sync.Mutex
entries map[string]cacheEntry
max int
ttl time.Duration
now func() time.Time
}
type cacheEntry struct {
field Field
expires time.Time
}
// NewCache returns a cache holding up to max entries for ttl each.
func NewCache(max int, ttl time.Duration) *Cache {
if max <= 0 {
max = 64
}
if ttl <= 0 {
ttl = 10 * time.Minute
}
return &Cache{
entries: make(map[string]cacheEntry, max),
max: max,
ttl: ttl,
now: time.Now,
}
}
// Get returns the cached field for key, if present and unexpired.
func (c *Cache) Get(key string) (Field, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[key]
if !ok || c.now().After(e.expires) {
return nil, false
}
return e.field, true
}
// Put stores field under key.
func (c *Cache) Put(key string, field Field) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.entries) >= c.max {
c.entries = make(map[string]cacheEntry, c.max)
}
c.entries[key] = cacheEntry{field: field, expires: c.now().Add(c.ttl)}
}

179
internal/windviz/windviz.go Normal file
View file

@ -0,0 +1,179 @@
// Package windviz rasterizes a weather.WindField into the JSON grid format
// consumed by browser velocity layers such as leaflet-velocity and
// wind-layer (the "gfs.json" / wind-js-server format).
//
// The module is decoupled from any specific dataset: it samples any
// weather.WindField on a regular latitude/longitude grid at a chosen time
// and altitude, downsampling by a configurable step to bound payload size.
package windviz
import (
"fmt"
"time"
"predictor-refactored/internal/weather"
)
// Request describes a wind-field rasterization.
type Request struct {
// Time is the forecast time to sample (UNIX seconds). Sampling outside
// the field's temporal coverage returns an error.
Time float64
// Altitude is the altitude in metres to sample at.
Altitude float64
// Bounding box in degrees. Latitudes in [-90, 90]; longitudes in
// [0, 360). For a global field use 0..360 (the rasterizer drops the
// duplicate 360° column).
MinLat, MaxLat float64
MinLng, MaxLng float64
// Step is the grid resolution in degrees (e.g. 1.0). Smaller is denser.
Step float64
}
// Component is one wind-js-server record: a header plus a flat data grid.
type Component struct {
Header Header `json:"header"`
Data []float64 `json:"data"`
}
// Header is the wind-js-server grid header. Field names and semantics match
// what leaflet-velocity / wind-layer expect.
type Header struct {
ParameterCategory int `json:"parameterCategory"`
ParameterNumber int `json:"parameterNumber"`
ParameterNumberName string `json:"parameterNumberName"`
ParameterUnit string `json:"parameterUnit"`
Nx int `json:"nx"`
Ny int `json:"ny"`
Lo1 float64 `json:"lo1"`
La1 float64 `json:"la1"`
Lo2 float64 `json:"lo2"`
La2 float64 `json:"la2"`
Dx float64 `json:"dx"`
Dy float64 `json:"dy"`
RefTime string `json:"refTime"`
ForecastTime int `json:"forecastTime"`
}
// Field is the two-component (U then V) payload. JSON-encoding a Field
// produces the array the velocity layers consume directly.
type Field []Component
const (
defaultStep = 1.0
minStep = 0.25 // clamp to bound output size
maxCells = 1 << 21
)
// 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.
func Rasterize(field weather.WindField, req Request) (Field, error) {
step := req.Step
if step <= 0 {
step = defaultStep
}
if step < minStep {
step = minStep
}
minLat, maxLat := req.MinLat, req.MaxLat
minLng, maxLng := req.MinLng, req.MaxLng
if minLat == 0 && maxLat == 0 {
minLat, maxLat = -90, 90
}
if minLng == 0 && maxLng == 0 {
minLng, maxLng = 0, 360
}
if maxLat <= minLat {
return nil, fmt.Errorf("invalid bounding box latitude")
}
// Longitudes may arrive in either the [0, 360) or the [-180, 180]
// convention (the latter is what the rest of the API emits). Detect a
// full-globe span first, then fold a regional box's western edge into
// [0, 360); per-cell sampling re-folds via normLng so an eastern edge
// past 360° still reads the correct column.
lngSpan := maxLng - minLng
if lngSpan <= 0 {
return nil, fmt.Errorf("invalid bounding box longitude")
}
global := lngSpan >= 360-1e-9
var nx int
if global {
// Drop the duplicate wrap column so the layer tiles cleanly.
minLng = 0
nx = int(360/step + 0.5)
maxLng = float64(nx-1) * step
} else {
minLng = normLng(minLng)
maxLng = minLng + lngSpan
nx = int(lngSpan/step+0.5) + 1
}
ny := int((maxLat-minLat)/step+0.5) + 1
if nx < 1 || ny < 1 {
return nil, fmt.Errorf("empty grid")
}
if nx*ny > maxCells {
return nil, fmt.Errorf("grid too large (%d cells); increase step or shrink bbox", nx*ny)
}
u := make([]float64, nx*ny)
v := make([]float64, nx*ny)
// Row 0 = north (la1); rows descend in latitude.
for j := range ny {
lat := maxLat - float64(j)*step
for i := range nx {
lng := minLng + float64(i)*step
s, err := field.Wind(req.Time, lat, normLng(lng), req.Altitude)
idx := j*nx + i
if err != nil {
continue // leave as 0
}
u[idx] = s.U
v[idx] = s.V
}
}
refTime := time.Unix(int64(req.Time), 0).UTC().Format("2006-01-02T15:04:05.000Z")
mk := func(num int, name string, data []float64) Component {
return Component{
Header: Header{
ParameterCategory: 2,
ParameterNumber: num,
ParameterNumberName: name,
ParameterUnit: "m.s-1",
Nx: nx,
Ny: ny,
Lo1: minLng,
La1: maxLat,
Lo2: maxLng,
La2: minLat,
Dx: step,
Dy: step,
RefTime: refTime,
ForecastTime: 0,
},
Data: data,
}
}
return Field{
mk(2, "eastward_wind", u),
mk(3, "northward_wind", v),
}, nil
}
// normLng folds a longitude into [0, 360) for sampling.
func normLng(lng float64) float64 {
for lng < 0 {
lng += 360
}
for lng >= 360 {
lng -= 360
}
return lng
}

View file

@ -0,0 +1,96 @@
package windviz
import (
"testing"
"time"
"predictor-refactored/internal/weather"
)
// constWind is a WindField returning a fixed sample everywhere.
type constWind struct {
u, v float64
epoch time.Time
}
func (c constWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) {
return weather.Sample{U: c.u, V: c.v}, nil
}
func (c constWind) Epoch() time.Time { return c.epoch }
func (c constWind) Source() string { return "test" }
func TestRasterizeGlobalDropsDuplicateColumn(t *testing.T) {
f := constWind{u: 5, v: -3, epoch: time.Unix(0, 0)}
out, err := Rasterize(f, Request{MinLng: 0, MaxLng: 360, Step: 90})
if err != nil {
t.Fatalf("Rasterize: %v", err)
}
if len(out) != 2 {
t.Fatalf("expected 2 components, got %d", len(out))
}
u := out[0]
// 360/90 = 4 columns (no duplicate 360°); lat -90..90 step 90 = 3 rows.
if u.Header.Nx != 4 || u.Header.Ny != 3 {
t.Errorf("grid = %dx%d, want 4x3", u.Header.Nx, u.Header.Ny)
}
if len(u.Data) != 12 {
t.Errorf("data len = %d, want 12", len(u.Data))
}
if u.Header.La1 != 90 || u.Header.La2 != -90 {
t.Errorf("lat range = %v..%v, want 90..-90 (north first)", u.Header.La1, u.Header.La2)
}
if u.Header.Lo1 != 0 || u.Header.Lo2 != 270 {
t.Errorf("lng range = %v..%v, want 0..270", u.Header.Lo1, u.Header.Lo2)
}
for _, d := range u.Data {
if d != 5 {
t.Errorf("U data = %v, want 5", d)
break
}
}
if out[0].Header.ParameterNumber != 2 || out[1].Header.ParameterNumber != 3 {
t.Errorf("component order should be U(2) then V(3)")
}
}
func TestRasterizeSignedLongitudeConvention(t *testing.T) {
f := constWind{u: 1, v: 2, epoch: time.Unix(0, 0)}
// A [-180, 180] global request must be detected as global and tiled
// without a duplicate seam column, identical to a 0..360 request.
signed, err := Rasterize(f, Request{MinLng: -180, MaxLng: 180, Step: 90})
if err != nil {
t.Fatalf("signed-global Rasterize: %v", err)
}
if signed[0].Header.Nx != 4 {
t.Errorf("signed-global nx = %d, want 4 (no duplicate column)", signed[0].Header.Nx)
}
// A western-hemisphere box must not 400; its western edge folds into [0,360).
west, err := Rasterize(f, Request{MinLat: 10, MaxLat: 20, MinLng: -100, MaxLng: -50, Step: 10})
if err != nil {
t.Fatalf("western-box Rasterize: %v", err)
}
if west[0].Header.Lo1 != 260 {
t.Errorf("western-box lo1 = %v, want 260 (=-100 folded)", west[0].Header.Lo1)
}
}
func TestRasterizeStepClamp(t *testing.T) {
f := constWind{epoch: time.Unix(0, 0)}
// step below min gets clamped, not rejected.
if _, err := Rasterize(f, Request{MinLat: -1, MaxLat: 1, MinLng: 0, MaxLng: 2, Step: 0.01}); err != nil {
t.Fatalf("Rasterize with tiny step: %v", err)
}
}
func TestCacheRoundTrip(t *testing.T) {
c := NewCache(2, time.Minute)
if _, ok := c.Get("a"); ok {
t.Errorf("empty cache should miss")
}
c.Put("a", Field{})
if _, ok := c.Get("a"); !ok {
t.Errorf("cache should hit after put")
}
}