feat: polish & windviz & deploy
This commit is contained in:
parent
81b8e763bd
commit
465ad00f7b
78 changed files with 20622 additions and 2154 deletions
63
internal/windviz/cache.go
Normal file
63
internal/windviz/cache.go
Normal 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
179
internal/windviz/windviz.go
Normal 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
|
||||
}
|
||||
96
internal/windviz/windviz_test.go
Normal file
96
internal/windviz/windviz_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue