feat: predictions

This commit is contained in:
Anatoly Antonov 2025-06-25 23:23:16 +03:00
parent 42e7924be9
commit 11be8f351f
42 changed files with 2221 additions and 516 deletions

View file

@ -1,21 +1,95 @@
package ds
import "time"
import (
"time"
api "git.intra.yksa.space/gsn/predictor/pkg/rest"
)
type PredictionParameters struct {
LaunchLatitude float64
LaunchLongitude float64
LaunchDatetime time.Time
LaunchAltitude float64
LaunchLatitude *float64
LaunchLongitude *float64
LaunchDatetime *time.Time
LaunchAltitude *float64
Profile *string
AscentRate *float64
BurstAltitude *float64
DescentRate *float64
FloatAltitude *float64
StopDatetime *time.Time
AscentCurve *string // base64
DescentCurve *string // base64
Interpolate *bool
Format *string
Dataset *time.Time
// Add other parameters as needed
}
type PredicitonResult struct {
Latitude float64
Longitude float64
Altitude float64
Timestamp time.Time
WindU float64
WindV float64
Latitude *float64
Longitude *float64
Altitude *float64
Timestamp *time.Time
WindU *float64
WindV *float64
// Add other result fields as needed
}
// ConvertOptPredictionParameters converts ogen's OptPredictionParameters to the internal pointer-based model.
// Returns nil if the input is not set.
func ConvertOptPredictionParameters(opt api.OptPredictionParameters) *PredictionParameters {
if !opt.Set {
return nil
}
in := opt.Value
out := &PredictionParameters{}
if v, ok := in.LaunchLatitude.Get(); ok {
out.LaunchLatitude = &v
}
if v, ok := in.LaunchLongitude.Get(); ok {
out.LaunchLongitude = &v
}
if v, ok := in.LaunchDatetime.Get(); ok {
out.LaunchDatetime = &v
}
if v, ok := in.LaunchAltitude.Get(); ok {
out.LaunchAltitude = &v
}
if v, ok := in.Profile.Get(); ok {
s := string(v)
out.Profile = &s
}
if v, ok := in.AscentRate.Get(); ok {
out.AscentRate = &v
}
if v, ok := in.BurstAltitude.Get(); ok {
out.BurstAltitude = &v
}
if v, ok := in.DescentRate.Get(); ok {
out.DescentRate = &v
}
if v, ok := in.FloatAltitude.Get(); ok {
out.FloatAltitude = &v
}
if v, ok := in.StopDatetime.Get(); ok {
out.StopDatetime = &v
}
if v, ok := in.AscentCurve.Get(); ok {
out.AscentCurve = &v
}
if v, ok := in.DescentCurve.Get(); ok {
out.DescentCurve = &v
}
if v, ok := in.Interpolate.Get(); ok {
out.Interpolate = &v
}
if v, ok := in.Format.Get(); ok {
s := string(v)
out.Format = &s
}
if v, ok := in.Dataset.Get(); ok {
out.Dataset = &v
}
return out
}

View file

@ -23,13 +23,11 @@ func (e *ErrorCode) Error() string {
return e.Message
}
// IsErr checks if the given error is an ErrorCode
func IsErr(err error) bool {
_, ok := err.(*ErrorCode)
return ok
}
// AsErr converts error to ErrorCode if possible
func AsErr(err error) (*ErrorCode, bool) {
if err == nil {
return nil, false
@ -38,7 +36,6 @@ func AsErr(err error) (*ErrorCode, bool) {
return errcode, ok
}
// Join combines multiple errors into a single ErrorCode
func Join(errs ...error) error {
if len(errs) == 0 {
return nil
@ -66,7 +63,6 @@ func Join(errs ...error) error {
return nil
}
// Use the first error's status code, or default to 500
statusCode := http.StatusInternalServerError
if len(errs) > 0 {
if errcode, ok := AsErr(errs[0]); ok {
@ -77,7 +73,6 @@ func Join(errs ...error) error {
return New(statusCode, strings.Join(messages, "; "), details...)
}
// Wrap wraps an error with additional context
func Wrap(err error, message string) error {
if err == nil {
return nil

View file

@ -1,81 +0,0 @@
package errcodes
import (
"testing"
)
func TestSpecificErrorTypes(t *testing.T) {
// Test Redis lock error
err := ErrRedisLockAlreadyLocked
if !IsErr(err) {
t.Error("Expected IsErr to return true for ErrorCode")
}
errcode, ok := AsErr(err)
if !ok {
t.Error("Expected AsErr to return true for ErrorCode")
}
if errcode != ErrRedisLockAlreadyLocked {
t.Error("Expected AsErr to return the same error")
}
// Test Redis cache miss error
cacheErr := ErrRedisCacheMiss
if !IsErr(cacheErr) {
t.Error("Expected IsErr to return true for cache miss error")
}
// Test configuration error
configErr := ErrConfigInvalidEnv
if !IsErr(configErr) {
t.Error("Expected IsErr to return true for config error")
}
// Test scheduler error
schedulerErr := ErrSchedulerTimeoutTooLong
if !IsErr(schedulerErr) {
t.Error("Expected IsErr to return true for scheduler error")
}
}
func TestErrorChecking(t *testing.T) {
// Example of how to check for specific errors in practice
err := ErrRedisLockAlreadyLocked
// Check if it's a specific error type
if errcode, ok := AsErr(err); ok {
switch errcode {
case ErrRedisLockAlreadyLocked:
// Handle lock already locked case
t.Log("Handling lock already locked error")
case ErrRedisCacheMiss:
// Handle cache miss case
t.Log("Handling cache miss error")
case ErrRedisCacheCorrupted:
// Handle corrupted cache case
t.Log("Handling corrupted cache error")
default:
// Handle other error types
t.Log("Handling other error type")
}
}
}
func TestWrapFunction(t *testing.T) {
originalErr := ErrRedisCacheMiss
wrappedErr := Wrap(originalErr, "additional context")
if !IsErr(wrappedErr) {
t.Error("Expected wrapped error to be an ErrorCode")
}
errcode, ok := AsErr(wrappedErr)
if !ok {
t.Error("Expected AsErr to work with wrapped error")
}
// The wrapped error should have the same status code as the original
if errcode.StatusCode != ErrRedisCacheMiss.StatusCode {
t.Errorf("Expected status code %d, got %d", ErrRedisCacheMiss.StatusCode, errcode.StatusCode)
}
}

View file

@ -20,12 +20,17 @@ type memCache struct {
func (c *memCache) get(k uint64) (vec, bool) {
if v, ok := c.m.Load(k); ok {
it := v.(item)
if time.Now().Before(it.exp) {
return it.v, true
}
c.m.Delete(k)
}
return vec{}, false
}
func (c *memCache) set(k uint64, v vec) { c.m.Store(k, item{v, time.Now().Add(c.ttl)}) }
func (c *memCache) set(k uint64, v vec) {
c.m.Store(k, item{v, time.Now().Add(c.ttl)})
}

View file

@ -1,8 +1,10 @@
package grib
import (
"net/url"
"time"
"git.intra.yksa.space/gsn/predictor/internal/pkg/errcodes"
env "github.com/caarlos0/env/v11"
)
type Config struct {
@ -11,5 +13,15 @@ type Config struct {
CacheTTL time.Duration `env:"CACHE_TTL" envDefault:"1h"`
Parallel int `env:"PARALLEL" envDefault:"4"`
Timeout time.Duration `env:"TIMEOUT" envDefault:"30s"`
DatasetURL url.URL `env:"DATASET_URL" envDefault:"https://nomads.ncep.noaa.gov/"`
DatasetURL string `env:"DATASET_URL" envDefault:"https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod"`
}
func NewConfig() (*Config, error) {
cfg := &Config{}
if err := env.ParseWithOptions(cfg, env.Options{
PrefixTagName: "GSN_PREDICTOR_GRIB_",
}); err != nil {
return nil, errcodes.Wrap(err, "failed to parse GRIB config")
}
return cfg, nil
}

View file

@ -9,7 +9,7 @@ import (
)
type cube struct {
mm mmap.MMap // readonly, U followed by V (float32 LE)
mm mmap.MMap
t, p, lat, lon int
bytesPerVar int64
file *os.File

View file

@ -13,17 +13,15 @@ import (
"golang.org/x/sync/errgroup"
)
// NOMADS only.
const nomadsRoot = "https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod"
type Downloader struct {
Dir string
Parallel int
Client *http.Client
Dir string
Parallel int
Client *http.Client
DatasetURL string
}
func (d *Downloader) fileURL(run string, hour int, step int) string {
return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2.0p50.f%03d", nomadsRoot, run, hour, hour, step)
return fmt.Sprintf("%s/gfs.%s/%02d/atmos/gfs.t%02dz.pgrb2.0p50.f%03d", d.DatasetURL, run, hour, hour, step)
}
func (d *Downloader) fetch(ctx context.Context, url, dst string) error {

View file

@ -27,15 +27,17 @@ type Service interface {
Update(ctx context.Context) error
Extract(ctx context.Context, lat, lon, alt float64, ts time.Time) ([2]float64, error)
Close() error
GetStatus() (ready bool, lastUpdate time.Time, isFresh bool, errMsg string)
}
type ServiceConfig struct {
Dir string
TTL time.Duration
CacheTTL time.Duration
Redis RedisIface
Parallel int
Client *http.Client
Dir string
TTL time.Duration
CacheTTL time.Duration
Redis RedisIface
Parallel int
Client *http.Client
DatasetURL string
}
type service struct {
@ -147,7 +149,7 @@ func (s *service) Update(ctx context.Context) error {
}
}
dl := Downloader{Dir: s.cfg.Dir, Parallel: s.cfg.Parallel, Client: s.cfg.Client}
dl := Downloader{Dir: s.cfg.Dir, Parallel: s.cfg.Parallel, Client: s.cfg.Client, DatasetURL: s.cfg.DatasetURL}
run := nearestRun(time.Now().UTC().Add(-4 * time.Hour))
// Check if we already have this run
@ -334,3 +336,16 @@ func (s *service) Close() error {
}
return nil
}
func (s *service) GetStatus() (ready bool, lastUpdate time.Time, isFresh bool, errMsg string) {
d := s.data.Load()
if d == nil {
return false, time.Time{}, false, "no dataset loaded"
}
runTime := time.Unix(d.runUTC, 0)
fresh := time.Since(runTime) < s.cfg.TTL
if !fresh {
return false, runTime, false, "dataset is too old"
}
return true, runTime, true, ""
}

View file

@ -1,62 +0,0 @@
package grib
import (
"context"
"testing"
"time"
)
func TestServiceCreation(t *testing.T) {
cfg := ServiceConfig{
Dir: "/tmp/grib_test",
TTL: 24 * time.Hour,
CacheTTL: 1 * time.Hour,
Redis: &MockRedis{},
Parallel: 2,
}
service, err := New(cfg)
if err != nil {
t.Fatalf("Failed to create service: %v", err)
}
defer service.Close()
if service == nil {
t.Fatal("Service is nil")
}
}
func TestNearestRun(t *testing.T) {
now := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
expected := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
result := nearestRun(now)
if !result.Equal(expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
func TestPressureFromAlt(t *testing.T) {
alt := 10000.0 // 10km
pressure := pressureFromAlt(alt)
// At 10km, pressure should be around 264 hPa
if pressure < 200 || pressure > 300 {
t.Errorf("Unexpected pressure at 10km: %f hPa", pressure)
}
}
// MockRedis for testing
type MockRedis struct{}
func (m *MockRedis) Lock(ctx context.Context, key string, ttl time.Duration) (func(context.Context), error) {
return func(ctx context.Context) {}, nil
}
func (m *MockRedis) Set(key string, value []byte, ttl time.Duration) error {
return nil
}
func (m *MockRedis) Get(key string) ([]byte, error) {
return nil, nil
}