179 lines
5.3 KiB
Go
179 lines
5.3 KiB
Go
// 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
|
|
}
|