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) } }