engine refactor
This commit is contained in:
parent
9e663db9dc
commit
81b8e763bd
37 changed files with 3532 additions and 1639 deletions
|
|
@ -1,34 +1,28 @@
|
|||
package gfs
|
||||
|
||||
import "fmt"
|
||||
// Cross-variant constants. Per-variant geometry (latitudes, longitudes,
|
||||
// pressure levels, hour step, max hour, URL token) lives on the Variant
|
||||
// type; see variant.go.
|
||||
|
||||
// Dataset shape: (hour, pressure_level, variable, latitude, longitude).
|
||||
// Matches the cube layout used by the reference Tawhiri implementation.
|
||||
const (
|
||||
NumHours = 65 // 0, 3, 6, ..., 192 hours forecast
|
||||
NumLevels = 47 // pressure levels
|
||||
NumVariables = 3 // geopotential height, U-wind, V-wind
|
||||
NumLatitudes = 361 // -90.0 to +90.0 inclusive in 0.5° steps
|
||||
NumLongitudes = 720 // 0.0 to 359.5 in 0.5° steps
|
||||
// NumVariables is the number of dataset variables: HGT, UGRD, VGRD.
|
||||
NumVariables = 3
|
||||
// ElementSize is the cell size in bytes (float32).
|
||||
ElementSize = 4
|
||||
|
||||
HourStep = 3
|
||||
MaxHour = 192
|
||||
Resolution = 0.5
|
||||
LatStart = -90.0
|
||||
LonStart = 0.0
|
||||
// LatStart is the first latitude in the cube (south to north).
|
||||
LatStart = -90.0
|
||||
// LonStart is the first longitude in the cube (0..360 east).
|
||||
LonStart = 0.0
|
||||
|
||||
// Variable indices within the cube's 3rd axis.
|
||||
VarHeight = 0
|
||||
VarWindU = 1
|
||||
VarWindV = 2
|
||||
|
||||
ElementSize = 4 // float32
|
||||
|
||||
// DatasetSize is the canonical file size: every grid cell × element size.
|
||||
DatasetSize int64 = int64(NumHours) * int64(NumLevels) * int64(NumVariables) *
|
||||
int64(NumLatitudes) * int64(NumLongitudes) * int64(ElementSize)
|
||||
)
|
||||
|
||||
// LevelSet identifies which GRIB file (primary/secondary) carries a level.
|
||||
// LevelSet identifies which GRIB file (primary or secondary) carries a
|
||||
// pressure level.
|
||||
type LevelSet int
|
||||
|
||||
const (
|
||||
|
|
@ -36,106 +30,5 @@ const (
|
|||
LevelSetB // pgrb2b — secondary file
|
||||
)
|
||||
|
||||
// Pressures lists the 47 pressure levels (hPa) in dataset index order,
|
||||
// descending from surface to top of atmosphere.
|
||||
var Pressures = [NumLevels]int{
|
||||
1000, 975, 950, 925, 900, 875, 850, 825, 800, 775,
|
||||
750, 725, 700, 675, 650, 625, 600, 575, 550, 525,
|
||||
500, 475, 450, 425, 400, 375, 350, 325, 300, 275,
|
||||
250, 225, 200, 175, 150, 125, 100, 70, 50, 30,
|
||||
20, 10, 7, 5, 3, 2, 1,
|
||||
}
|
||||
|
||||
// PressuresPgrb2 lists the levels carried by the primary GRIB file.
|
||||
var PressuresPgrb2 = []int{
|
||||
10, 20, 30, 50, 70, 100, 150, 200, 250, 300, 350, 400,
|
||||
450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 925,
|
||||
950, 975, 1000,
|
||||
}
|
||||
|
||||
// PressuresPgrb2b lists the levels carried by the secondary GRIB file.
|
||||
var PressuresPgrb2b = []int{
|
||||
1, 2, 3, 5, 7, 125, 175, 225, 275, 325, 375, 425,
|
||||
475, 525, 575, 625, 675, 725, 775, 825, 875,
|
||||
}
|
||||
|
||||
var pressureIndex map[int]int
|
||||
var pressureLevelSet map[int]LevelSet
|
||||
|
||||
func init() {
|
||||
pressureIndex = make(map[int]int, NumLevels)
|
||||
for i, p := range Pressures {
|
||||
pressureIndex[p] = i
|
||||
}
|
||||
pressureLevelSet = make(map[int]LevelSet, NumLevels)
|
||||
for _, p := range PressuresPgrb2 {
|
||||
pressureLevelSet[p] = LevelSetA
|
||||
}
|
||||
for _, p := range PressuresPgrb2b {
|
||||
pressureLevelSet[p] = LevelSetB
|
||||
}
|
||||
}
|
||||
|
||||
// PressureIndex returns the dataset index for a pressure level in hPa,
|
||||
// or -1 when the level is unknown.
|
||||
func PressureIndex(hPa int) int {
|
||||
idx, ok := pressureIndex[hPa]
|
||||
if !ok {
|
||||
return -1
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// PressureLevelSet returns the GRIB file set carrying a pressure level.
|
||||
func PressureLevelSet(hPa int) (LevelSet, bool) {
|
||||
ls, ok := pressureLevelSet[hPa]
|
||||
return ls, ok
|
||||
}
|
||||
|
||||
// HourIndex returns the dataset time index for a forecast hour, or -1 when
|
||||
// the hour is outside the range or not a multiple of HourStep.
|
||||
func HourIndex(hour int) int {
|
||||
if hour < 0 || hour > MaxHour || hour%HourStep != 0 {
|
||||
return -1
|
||||
}
|
||||
return hour / HourStep
|
||||
}
|
||||
|
||||
// Hours returns the full list of forecast hours, [0, 3, 6, ..., MaxHour].
|
||||
func Hours() []int {
|
||||
out := make([]int, 0, NumHours)
|
||||
for h := 0; h <= MaxHour; h += HourStep {
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VariableIndex maps a GRIB (category, number) pair to a dataset variable
|
||||
// index, returning -1 for parameters this dataset does not store.
|
||||
func VariableIndex(parameterCategory, parameterNumber int) int {
|
||||
switch {
|
||||
case parameterCategory == 3 && parameterNumber == 5:
|
||||
return VarHeight
|
||||
case parameterCategory == 2 && parameterNumber == 2:
|
||||
return VarWindU
|
||||
case parameterCategory == 2 && parameterNumber == 3:
|
||||
return VarWindV
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// S3 URL configuration for NOAA GFS data on the public S3 mirror.
|
||||
// S3BaseURL is the public NOAA S3 mirror.
|
||||
const S3BaseURL = "https://noaa-gfs-bdp-pds.s3.amazonaws.com"
|
||||
|
||||
// GribURL returns the S3 URL for a primary (pgrb2) GRIB file.
|
||||
func GribURL(date string, runHour, forecastStep int) string {
|
||||
return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2.0p50.f%03d",
|
||||
S3BaseURL, date, runHour, runHour, forecastStep)
|
||||
}
|
||||
|
||||
// GribURLB returns the S3 URL for a secondary (pgrb2b) GRIB file.
|
||||
func GribURLB(date string, runHour, forecastStep int) string {
|
||||
return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2b.0p50.f%03d",
|
||||
S3BaseURL, date, runHour, runHour, forecastStep)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import (
|
|||
)
|
||||
|
||||
// File is an mmap-backed wind dataset file. The layout is a flat C-order
|
||||
// row-major array of float32 values, shape (hour, level, variable, lat, lng).
|
||||
// row-major float32 array, shape (hour, level, variable, lat, lng), with
|
||||
// the per-axis sizes coming from Variant.
|
||||
type File struct {
|
||||
variant *Variant
|
||||
mm mmap.MMap
|
||||
file *os.File
|
||||
writable bool
|
||||
|
|
@ -20,8 +22,11 @@ type File struct {
|
|||
Epoch time.Time
|
||||
}
|
||||
|
||||
// Variant returns the Variant the file was created with.
|
||||
func (d *File) Variant() *Variant { return d.variant }
|
||||
|
||||
// Open opens an existing dataset file for reading.
|
||||
func Open(path string, epoch time.Time) (*File, error) {
|
||||
func Open(path string, variant *Variant, epoch time.Time) (*File, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open dataset: %w", err)
|
||||
|
|
@ -31,39 +36,40 @@ func Open(path string, epoch time.Time) (*File, error) {
|
|||
f.Close()
|
||||
return nil, fmt.Errorf("stat dataset: %w", err)
|
||||
}
|
||||
if info.Size() != DatasetSize {
|
||||
if info.Size() != variant.DatasetSize() {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("dataset should be %d bytes (was %d)", DatasetSize, info.Size())
|
||||
return nil, fmt.Errorf("dataset should be %d bytes (was %d)", variant.DatasetSize(), info.Size())
|
||||
}
|
||||
mm, err := mmap.Map(f, mmap.RDONLY, 0)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("mmap dataset: %w", err)
|
||||
}
|
||||
return &File{mm: mm, file: f, writable: false, Epoch: epoch}, nil
|
||||
return &File{variant: variant, mm: mm, file: f, writable: false, Epoch: epoch}, nil
|
||||
}
|
||||
|
||||
// Create creates a new dataset file of the canonical size, mmap'd read-write.
|
||||
func Create(path string) (*File, error) {
|
||||
// Create creates a new dataset file sized for variant, mmap'd read-write.
|
||||
func Create(path string, variant *Variant) (*File, error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create dataset: %w", err)
|
||||
}
|
||||
if err := f.Truncate(DatasetSize); err != nil {
|
||||
size := variant.DatasetSize()
|
||||
if err := f.Truncate(size); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("truncate dataset: %w", err)
|
||||
}
|
||||
mm, err := mmap.MapRegion(f, int(DatasetSize), mmap.RDWR, 0, 0)
|
||||
mm, err := mmap.MapRegion(f, int(size), mmap.RDWR, 0, 0)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("mmap dataset: %w", err)
|
||||
}
|
||||
return &File{mm: mm, file: f, writable: true}, nil
|
||||
return &File{variant: variant, mm: mm, file: f, writable: true}, nil
|
||||
}
|
||||
|
||||
// OpenWritable opens an existing dataset file for read-write access.
|
||||
// Used when resuming a partial download.
|
||||
func OpenWritable(path string) (*File, error) {
|
||||
// OpenWritable opens an existing dataset file for read-write access. Used
|
||||
// when resuming a partial download.
|
||||
func OpenWritable(path string, variant *Variant) (*File, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open dataset rw: %w", err)
|
||||
|
|
@ -73,51 +79,55 @@ func OpenWritable(path string) (*File, error) {
|
|||
f.Close()
|
||||
return nil, fmt.Errorf("stat dataset: %w", err)
|
||||
}
|
||||
if info.Size() != DatasetSize {
|
||||
if info.Size() != variant.DatasetSize() {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("dataset should be %d bytes (was %d)", DatasetSize, info.Size())
|
||||
return nil, fmt.Errorf("dataset should be %d bytes (was %d)", variant.DatasetSize(), info.Size())
|
||||
}
|
||||
mm, err := mmap.MapRegion(f, int(DatasetSize), mmap.RDWR, 0, 0)
|
||||
mm, err := mmap.MapRegion(f, int(info.Size()), mmap.RDWR, 0, 0)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("mmap dataset: %w", err)
|
||||
}
|
||||
return &File{mm: mm, file: f, writable: true}, nil
|
||||
return &File{variant: variant, mm: mm, file: f, writable: true}, nil
|
||||
}
|
||||
|
||||
// offset returns the byte offset of the [hour][level][variable][lat][lng] cell.
|
||||
func offset(hour, level, variable, lat, lng int) int64 {
|
||||
func (d *File) offset(hour, level, variable, lat, lng int) int64 {
|
||||
v := d.variant
|
||||
idx := int64(hour)
|
||||
idx = idx*int64(NumLevels) + int64(level)
|
||||
idx = idx*int64(v.NumLevels()) + int64(level)
|
||||
idx = idx*int64(NumVariables) + int64(variable)
|
||||
idx = idx*int64(NumLatitudes) + int64(lat)
|
||||
idx = idx*int64(NumLongitudes) + int64(lng)
|
||||
idx = idx*int64(v.NumLatitudes()) + int64(lat)
|
||||
idx = idx*int64(v.NumLongitudes()) + int64(lng)
|
||||
return idx * int64(ElementSize)
|
||||
}
|
||||
|
||||
// Val reads one cell as a float32.
|
||||
func (d *File) Val(hour, level, variable, lat, lng int) float32 {
|
||||
off := offset(hour, level, variable, lat, lng)
|
||||
off := d.offset(hour, level, variable, lat, lng)
|
||||
return math.Float32frombits(binary.LittleEndian.Uint32(d.mm[off : off+4]))
|
||||
}
|
||||
|
||||
// SetVal writes one cell. Only valid on writable files.
|
||||
func (d *File) SetVal(hour, level, variable, lat, lng int, val float32) {
|
||||
off := offset(hour, level, variable, lat, lng)
|
||||
off := d.offset(hour, level, variable, lat, lng)
|
||||
binary.LittleEndian.PutUint32(d.mm[off:off+4], math.Float32bits(val))
|
||||
}
|
||||
|
||||
// BlitGribData copies one decoded GRIB grid into the dataset, flipping the
|
||||
// latitude axis from GRIB's north-to-south scan order to our south-to-north
|
||||
// storage order. gribData must be 361*720 = 259920 float64 values.
|
||||
// storage order.
|
||||
func (d *File) BlitGribData(hourIdx, levelIdx, varIdx int, gribData []float64) error {
|
||||
expected := NumLatitudes * NumLongitudes
|
||||
v := d.variant
|
||||
expected := v.NumLatitudes() * v.NumLongitudes()
|
||||
if len(gribData) != expected {
|
||||
return fmt.Errorf("grib data has %d values, expected %d", len(gribData), expected)
|
||||
}
|
||||
for lat := range NumLatitudes {
|
||||
for lng := range NumLongitudes {
|
||||
gribIdx := (360-lat)*NumLongitudes + lng
|
||||
lats := v.NumLatitudes()
|
||||
lngs := v.NumLongitudes()
|
||||
for lat := range lats {
|
||||
for lng := range lngs {
|
||||
gribIdx := (lats-1-lat)*lngs + lng
|
||||
d.SetVal(hourIdx, levelIdx, varIdx, lat, lng, float32(gribData[gribIdx]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
internal/weather/gfs/gefs_variants.go
Normal file
68
internal/weather/gfs/gefs_variants.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package gfs
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Family is the dataset family ("gfs" or "gefs"). Variants of different
|
||||
// families have different URL layouts but share the cube format.
|
||||
type Family int
|
||||
|
||||
const (
|
||||
FamilyGFS Family = iota
|
||||
FamilyGEFS
|
||||
)
|
||||
|
||||
func (f Family) String() string {
|
||||
switch f {
|
||||
case FamilyGEFS:
|
||||
return "gefs"
|
||||
default:
|
||||
return "gfs"
|
||||
}
|
||||
}
|
||||
|
||||
// HasMember reports whether the family requires a member index in URLs.
|
||||
func (f Family) HasMember() bool { return f == FamilyGEFS }
|
||||
|
||||
// GEFS variant constants.
|
||||
//
|
||||
// The 21-member ensemble is gec00 (control) + gep01..gep20 (perturbations).
|
||||
// NOAA publishes more members today but 21 matches the historical Tawhiri
|
||||
// configuration and is what the phase 2 spec calls for.
|
||||
const GEFSMembers = 21
|
||||
|
||||
// GefsMemberName returns the file-name token for a GEFS member.
|
||||
// member=0 → "gec00", member=1..20 → "gep01".."gep20".
|
||||
func GefsMemberName(member int) string {
|
||||
if member == 0 {
|
||||
return "gec00"
|
||||
}
|
||||
return fmt.Sprintf("gep%02d", member)
|
||||
}
|
||||
|
||||
// GEFS S3 mirror.
|
||||
const GEFSS3BaseURL = "https://noaa-gefs-pds.s3.amazonaws.com"
|
||||
|
||||
// GefsGribURL returns the S3 URL for a GEFS primary GRIB file.
|
||||
func GefsGribURL(date string, runHour, member, forecastStep int, resToken string) string {
|
||||
return fmt.Sprintf("%s/gefs.%s/%02d/atmos/pgrb2ap5/%s.t%02dz.pgrb2a.%s.f%03d",
|
||||
GEFSS3BaseURL, date, runHour, GefsMemberName(member), runHour, resToken, forecastStep)
|
||||
}
|
||||
|
||||
// GefsGribURLB returns the S3 URL for a GEFS secondary GRIB file.
|
||||
func GefsGribURLB(date string, runHour, member, forecastStep int, resToken string) string {
|
||||
return fmt.Sprintf("%s/gefs.%s/%02d/atmos/pgrb2bp5/%s.t%02dz.pgrb2b.%s.f%03d",
|
||||
GEFSS3BaseURL, date, runHour, GefsMemberName(member), runHour, resToken, forecastStep)
|
||||
}
|
||||
|
||||
// GEFS variants — 0.5° resolution, 3-hour cadence, 192h horizon.
|
||||
var GEFS0p50_3h = &Variant{
|
||||
ID: "gefs-0p50-3h",
|
||||
Family: FamilyGEFS,
|
||||
ResToken: "0p50",
|
||||
Resolution: 0.5,
|
||||
HourStep: 3,
|
||||
MaxHour: 192,
|
||||
Pressures: GFS0p50_3h.Pressures,
|
||||
PressuresPgrb2: GFS0p50_3h.PressuresPgrb2,
|
||||
PressuresPgrb2b: GFS0p50_3h.PressuresPgrb2b,
|
||||
}
|
||||
191
internal/weather/gfs/variant.go
Normal file
191
internal/weather/gfs/variant.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package gfs
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Variant describes one configuration of a NOAA dataset family (GFS or GEFS).
|
||||
//
|
||||
// The dataset cube is a 5-D float32 array with shape
|
||||
// (NumHours, NumLevels, NumVariables, NumLatitudes, NumLongitudes) where
|
||||
// NumVariables and ElementSize are fixed across all GFS variants but the
|
||||
// other dimensions depend on the resolution and forecast cadence.
|
||||
type Variant struct {
|
||||
// ID is a stable identifier ("gfs-0p50-3h", "gefs-0p50-3h", ...).
|
||||
ID string
|
||||
// Family identifies the dataset family the variant belongs to.
|
||||
Family Family
|
||||
|
||||
// Resolution token used in NOAA URLs ("0p50", "0p25").
|
||||
ResToken string
|
||||
// Grid step in degrees (0.5, 0.25). 180 / Resolution + 1 latitudes and
|
||||
// 360 / Resolution longitudes.
|
||||
Resolution float64
|
||||
|
||||
HourStep int // hours between forecast steps
|
||||
MaxHour int // largest forecast hour (inclusive)
|
||||
|
||||
// Pressures lists every pressure level in dataset index order, descending.
|
||||
Pressures []int
|
||||
// PressuresPgrb2 / PressuresPgrb2b split the pressures between the two
|
||||
// downloaded GRIB files. Their union must equal Pressures.
|
||||
PressuresPgrb2 []int
|
||||
PressuresPgrb2b []int
|
||||
|
||||
pressureIndex map[int]int
|
||||
pressureLevelSet map[int]LevelSet
|
||||
}
|
||||
|
||||
// NumHours returns MaxHour/HourStep + 1.
|
||||
func (v *Variant) NumHours() int { return v.MaxHour/v.HourStep + 1 }
|
||||
|
||||
// NumLevels returns len(Pressures).
|
||||
func (v *Variant) NumLevels() int { return len(v.Pressures) }
|
||||
|
||||
// NumLatitudes returns 180/Resolution + 1.
|
||||
func (v *Variant) NumLatitudes() int { return int(180.0/v.Resolution) + 1 }
|
||||
|
||||
// NumLongitudes returns 360/Resolution.
|
||||
func (v *Variant) NumLongitudes() int { return int(360.0 / v.Resolution) }
|
||||
|
||||
// DatasetSize returns the canonical file size in bytes.
|
||||
func (v *Variant) DatasetSize() int64 {
|
||||
return int64(v.NumHours()) * int64(v.NumLevels()) * int64(NumVariables) *
|
||||
int64(v.NumLatitudes()) * int64(v.NumLongitudes()) * int64(ElementSize)
|
||||
}
|
||||
|
||||
// Hours returns the full list of forecast hours [0, HourStep, ..., MaxHour].
|
||||
func (v *Variant) Hours() []int {
|
||||
out := make([]int, 0, v.NumHours())
|
||||
for h := 0; h <= v.MaxHour; h += v.HourStep {
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HourIndex returns the dataset time index for an hour, or -1 if invalid.
|
||||
func (v *Variant) HourIndex(hour int) int {
|
||||
if hour < 0 || hour > v.MaxHour || hour%v.HourStep != 0 {
|
||||
return -1
|
||||
}
|
||||
return hour / v.HourStep
|
||||
}
|
||||
|
||||
// PressureIndex returns the dataset index for a pressure level in hPa,
|
||||
// or -1 when the level is unknown to this variant.
|
||||
func (v *Variant) PressureIndex(hPa int) int {
|
||||
v.indexLazyInit()
|
||||
if i, ok := v.pressureIndex[hPa]; ok {
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// PressureLevelSet returns the GRIB file set carrying a pressure level.
|
||||
func (v *Variant) PressureLevelSet(hPa int) (LevelSet, bool) {
|
||||
v.indexLazyInit()
|
||||
ls, ok := v.pressureLevelSet[hPa]
|
||||
return ls, ok
|
||||
}
|
||||
|
||||
// VariableIndex maps a GRIB (category, number) pair to a dataset variable index.
|
||||
func (v *Variant) VariableIndex(parameterCategory, parameterNumber int) int {
|
||||
switch {
|
||||
case parameterCategory == 3 && parameterNumber == 5:
|
||||
return VarHeight
|
||||
case parameterCategory == 2 && parameterNumber == 2:
|
||||
return VarWindU
|
||||
case parameterCategory == 2 && parameterNumber == 3:
|
||||
return VarWindV
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// GribURL returns the S3 URL for the primary (pgrb2) GRIB file.
|
||||
func (v *Variant) GribURL(date string, runHour, forecastStep int) string {
|
||||
return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2.%s.f%03d",
|
||||
S3BaseURL, date, runHour, runHour, v.ResToken, forecastStep)
|
||||
}
|
||||
|
||||
// GribURLB returns the S3 URL for the secondary (pgrb2b) GRIB file.
|
||||
func (v *Variant) GribURLB(date string, runHour, forecastStep int) string {
|
||||
return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2b.%s.f%03d",
|
||||
S3BaseURL, date, runHour, runHour, v.ResToken, forecastStep)
|
||||
}
|
||||
|
||||
func (v *Variant) indexLazyInit() {
|
||||
if v.pressureIndex != nil {
|
||||
return
|
||||
}
|
||||
v.pressureIndex = make(map[int]int, len(v.Pressures))
|
||||
for i, p := range v.Pressures {
|
||||
v.pressureIndex[p] = i
|
||||
}
|
||||
v.pressureLevelSet = make(map[int]LevelSet, len(v.Pressures))
|
||||
for _, p := range v.PressuresPgrb2 {
|
||||
v.pressureLevelSet[p] = LevelSetA
|
||||
}
|
||||
for _, p := range v.PressuresPgrb2b {
|
||||
v.pressureLevelSet[p] = LevelSetB
|
||||
}
|
||||
}
|
||||
|
||||
// Standard variants -- these mirror what NOAA publishes today.
|
||||
//
|
||||
// GFS0p50_3h is the historical Tawhiri default: 0.5° resolution, 3-hour
|
||||
// forecast cadence, 0..192h horizon, 47 pressure levels split across the
|
||||
// primary and secondary GRIB files.
|
||||
//
|
||||
// GFS0p25_3h mirrors the same 3-hour cadence at 0.25° resolution (the
|
||||
// horizon is larger in practice but we keep 192h for parity with 0p50).
|
||||
//
|
||||
// GFS0p25_1h targets the 1-hourly portion NOAA publishes out to 120h.
|
||||
var (
|
||||
GFS0p50_3h = &Variant{
|
||||
ID: "gfs-0p50-3h",
|
||||
ResToken: "0p50",
|
||||
Resolution: 0.5,
|
||||
HourStep: 3,
|
||||
MaxHour: 192,
|
||||
Pressures: []int{1000, 975, 950, 925, 900, 875, 850, 825, 800, 775, 750, 725, 700, 675, 650, 625, 600, 575, 550, 525, 500, 475, 450, 425, 400, 375, 350, 325, 300, 275, 250, 225, 200, 175, 150, 125, 100, 70, 50, 30, 20, 10, 7, 5, 3, 2, 1},
|
||||
PressuresPgrb2: []int{10, 20, 30, 50, 70, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 925, 950, 975, 1000},
|
||||
PressuresPgrb2b: []int{1, 2, 3, 5, 7, 125, 175, 225, 275, 325, 375, 425, 475, 525, 575, 625, 675, 725, 775, 825, 875},
|
||||
}
|
||||
|
||||
GFS0p25_3h = &Variant{
|
||||
ID: "gfs-0p25-3h",
|
||||
ResToken: "0p25",
|
||||
Resolution: 0.25,
|
||||
HourStep: 3,
|
||||
MaxHour: 192,
|
||||
Pressures: GFS0p50_3h.Pressures,
|
||||
PressuresPgrb2: GFS0p50_3h.PressuresPgrb2,
|
||||
PressuresPgrb2b: GFS0p50_3h.PressuresPgrb2b,
|
||||
}
|
||||
|
||||
GFS0p25_1h = &Variant{
|
||||
ID: "gfs-0p25-1h",
|
||||
ResToken: "0p25",
|
||||
Resolution: 0.25,
|
||||
HourStep: 1,
|
||||
MaxHour: 120,
|
||||
Pressures: GFS0p50_3h.Pressures,
|
||||
PressuresPgrb2: GFS0p50_3h.PressuresPgrb2,
|
||||
PressuresPgrb2b: GFS0p50_3h.PressuresPgrb2b,
|
||||
}
|
||||
)
|
||||
|
||||
// VariantByID returns one of the predefined variants by its ID.
|
||||
func VariantByID(id string) (*Variant, error) {
|
||||
switch id {
|
||||
case GFS0p50_3h.ID:
|
||||
return GFS0p50_3h, nil
|
||||
case GFS0p25_3h.ID:
|
||||
return GFS0p25_3h, nil
|
||||
case GFS0p25_1h.ID:
|
||||
return GFS0p25_1h, nil
|
||||
case GEFS0p50_3h.ID:
|
||||
return GEFS0p50_3h, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown variant %q", id)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,45 +10,49 @@ import (
|
|||
// Wind is a WindField backed by a GFS dataset file.
|
||||
type Wind struct {
|
||||
file *File
|
||||
|
||||
hourAxis numerics.Axis
|
||||
latAxis numerics.Axis
|
||||
lngAxis numerics.Axis
|
||||
}
|
||||
|
||||
// NewWind returns a Wind backed by file.
|
||||
// NewWind returns a Wind backed by file. The axes are constructed from the
|
||||
// file's variant geometry.
|
||||
func NewWind(file *File) *Wind {
|
||||
return &Wind{file: file}
|
||||
v := file.variant
|
||||
return &Wind{
|
||||
file: file,
|
||||
hourAxis: numerics.Axis{
|
||||
Left: 0,
|
||||
Step: float64(v.HourStep),
|
||||
N: v.NumHours(),
|
||||
Name: "hour",
|
||||
},
|
||||
latAxis: numerics.Axis{
|
||||
Left: LatStart,
|
||||
Step: v.Resolution,
|
||||
N: v.NumLatitudes(),
|
||||
Name: "lat",
|
||||
},
|
||||
lngAxis: numerics.Axis{
|
||||
Left: LonStart,
|
||||
Step: v.Resolution,
|
||||
N: v.NumLongitudes(),
|
||||
Wrap: true,
|
||||
Name: "lng",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Epoch returns the forecast run time of the underlying file.
|
||||
func (w *Wind) Epoch() time.Time { return w.file.Epoch }
|
||||
|
||||
// Source returns the source identifier "noaa-gfs-0p50".
|
||||
func (w *Wind) Source() string { return "noaa-gfs-0p50" }
|
||||
// Source returns the variant ID (e.g. "gfs-0p50-3h").
|
||||
func (w *Wind) Source() string { return w.file.variant.ID }
|
||||
|
||||
// Close releases the underlying file's resources.
|
||||
func (w *Wind) Close() error { return w.file.Close() }
|
||||
|
||||
// Grid axes for the GFS 0.5-degree dataset.
|
||||
var (
|
||||
hourAxis = numerics.Axis{
|
||||
Left: 0,
|
||||
Step: float64(HourStep),
|
||||
N: NumHours,
|
||||
Name: "hour",
|
||||
}
|
||||
latAxis = numerics.Axis{
|
||||
Left: LatStart,
|
||||
Step: Resolution,
|
||||
N: NumLatitudes,
|
||||
Name: "lat",
|
||||
}
|
||||
lngAxis = numerics.Axis{
|
||||
Left: LonStart,
|
||||
Step: Resolution,
|
||||
N: NumLongitudes,
|
||||
Wrap: true,
|
||||
Name: "lng",
|
||||
}
|
||||
)
|
||||
|
||||
// Wind samples the field at the given UNIX time, geographic coordinate, and
|
||||
// altitude. Vertical interpolation matches Tawhiri: locate the two pressure
|
||||
// levels whose interpolated geopotential heights bracket alt, then linearly
|
||||
|
|
@ -56,15 +60,15 @@ var (
|
|||
func (w *Wind) Wind(t, lat, lng, alt float64) (weather.Sample, error) {
|
||||
hours := (t - float64(w.file.Epoch.Unix())) / 3600.0
|
||||
|
||||
bh, err := hourAxis.Locate(hours)
|
||||
bh, err := w.hourAxis.Locate(hours)
|
||||
if err != nil {
|
||||
return weather.Sample{}, err
|
||||
}
|
||||
bla, err := latAxis.Locate(lat)
|
||||
bla, err := w.latAxis.Locate(lat)
|
||||
if err != nil {
|
||||
return weather.Sample{}, err
|
||||
}
|
||||
bln, err := lngAxis.Locate(lng)
|
||||
bln, err := w.lngAxis.Locate(lng)
|
||||
if err != nil {
|
||||
return weather.Sample{}, err
|
||||
}
|
||||
|
|
@ -76,7 +80,7 @@ func (w *Wind) Wind(t, lat, lng, alt float64) (weather.Sample, error) {
|
|||
}
|
||||
}
|
||||
|
||||
levelIdx := numerics.Bisect(0, NumLevels-2, alt, func(level int) float64 {
|
||||
levelIdx := numerics.Bisect(0, w.file.variant.NumLevels()-2, alt, func(level int) float64 {
|
||||
return numerics.EvalTrilinear(bs, height(level))
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue