feat: polish & windviz & deploy
This commit is contained in:
parent
81b8e763bd
commit
465ad00f7b
78 changed files with 20622 additions and 2154 deletions
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue