feat: predictions
This commit is contained in:
parent
42e7924be9
commit
11be8f351f
42 changed files with 2221 additions and 516 deletions
|
|
@ -1,8 +1,23 @@
|
|||
package updater
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/errcodes"
|
||||
env "github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Interval time.Duration `env:"INTERVAL" envDefault:"6h"`
|
||||
Timeout time.Duration `env:"TIMEOUT" envDefault:"45m"`
|
||||
}
|
||||
|
||||
func NewConfig() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
if err := env.ParseWithOptions(cfg, env.Options{
|
||||
PrefixTagName: "GSN_PREDICTOR_GRIB_UPDATER_",
|
||||
}); err != nil {
|
||||
return nil, errcodes.Wrap(err, "failed to parse GRIB updater config")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,19 @@ import (
|
|||
"time"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/errcodes"
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/log"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
service GribService
|
||||
config *Config
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func New(service GribService, config *Config, logger *zap.Logger) *Job {
|
||||
func New(service GribService, config *Config) *Job {
|
||||
return &Job{
|
||||
service: service,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,21 +30,22 @@ func (j *Job) GetTimeout() time.Duration {
|
|||
}
|
||||
|
||||
func (j *Job) GetCount() int {
|
||||
return 0 // Run indefinitely
|
||||
return 1
|
||||
}
|
||||
|
||||
func (j *Job) GetAsync() bool {
|
||||
return false // Singleton mode - only one instance should run
|
||||
return false
|
||||
}
|
||||
|
||||
func (j *Job) Execute(ctx context.Context) error {
|
||||
j.logger.Info("executing GRIB update job")
|
||||
log := log.Ctx(ctx)
|
||||
log.Info("executing GRIB update job")
|
||||
|
||||
if err := j.service.Update(ctx); err != nil {
|
||||
j.logger.Error("GRIB update failed", zap.Error(err))
|
||||
log.Error("GRIB update failed", zap.Error(err))
|
||||
return errcodes.Wrap(err, "failed to update GRIB data")
|
||||
}
|
||||
|
||||
j.logger.Info("GRIB update completed successfully")
|
||||
log.Info("GRIB update completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
)
|
||||
|
||||
type cube struct {
|
||||
mm mmap.MMap // read‑only, U followed by V (float32 LE)
|
||||
mm mmap.MMap
|
||||
t, p, lat, lon int
|
||||
bytesPerVar int64
|
||||
file *os.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 {
|
||||
|
|
|
|||
|
|
@ -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, ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -2,21 +2,27 @@ package service
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/grib"
|
||||
"git.intra.yksa.space/gsn/predictor/pkg/redis"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// GRIB Configuration
|
||||
Grib grib.Config `envPrefix:"GRIB_"`
|
||||
// --- GRIB Configuration ---
|
||||
GribDir string `env:"GSN_PREDICTOR_GRIB_DIR" envDefault:"/tmp/grib"`
|
||||
GribTTL time.Duration `env:"GSN_PREDICTOR_GRIB_TTL" envDefault:"24h"`
|
||||
GribCacheTTL time.Duration `env:"GSN_PREDICTOR_GRIB_CACHE_TTL" envDefault:"1h"`
|
||||
GribParallel int `env:"GSN_PREDICTOR_GRIB_PARALLEL" envDefault:"4"`
|
||||
GribTimeout time.Duration `env:"GSN_PREDICTOR_GRIB_TIMEOUT" envDefault:"30s"`
|
||||
GribDatasetURL string `env:"GSN_PREDICTOR_GRIB_DATASET_URL" envDefault:"https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod"`
|
||||
|
||||
// Redis Configuration
|
||||
Redis redis.Config `envPrefix:"REDIS_"`
|
||||
// --- Redis Configuration ---
|
||||
RedisHost string `env:"GSN_PREDICTOR_REDIS_HOST"`
|
||||
RedisPort int `env:"GSN_PREDICTOR_REDIS_PORT"`
|
||||
RedisPassword string `env:"GSN_PREDICTOR_REDIS_PASSWORD"`
|
||||
RedisDB int `env:"GSN_PREDICTOR_REDIS_DB"`
|
||||
}
|
||||
|
||||
func (c *Config) CreateHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: c.Grib.Timeout,
|
||||
Timeout: c.GribTimeout,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,26 +2,494 @@ package service
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/ds"
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/errcodes"
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/log"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var ErrInvalidParameters = errcodes.New(400, "missing required prediction parameters")
|
||||
|
||||
// Stage represents a prediction stage (ascent, descent, float)
|
||||
type Stage struct {
|
||||
Name string
|
||||
Results []ds.PredicitonResult
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
}
|
||||
|
||||
// CustomCurve represents a custom ascent/descent curve
|
||||
type CustomCurve struct {
|
||||
Altitude []float64 `json:"altitude"`
|
||||
Time []float64 `json:"time"` // seconds from start
|
||||
}
|
||||
|
||||
func (s *Service) PerformPrediction(ctx context.Context, params ds.PredictionParameters) ([]ds.PredicitonResult, error) {
|
||||
// Extract wind data at launch point
|
||||
wind, err := s.ExtractWind(ctx, params.LaunchLatitude, params.LaunchLongitude, params.LaunchAltitude, params.LaunchDatetime)
|
||||
// Validate required parameters
|
||||
if params.LaunchLatitude == nil || params.LaunchLongitude == nil || params.LaunchAltitude == nil || params.LaunchDatetime == nil {
|
||||
return nil, ErrInvalidParameters
|
||||
}
|
||||
|
||||
// Get default values
|
||||
profile := "standard_profile"
|
||||
if params.Profile != nil {
|
||||
profile = *params.Profile
|
||||
}
|
||||
|
||||
ascentRate := 5.0
|
||||
if params.AscentRate != nil {
|
||||
ascentRate = *params.AscentRate
|
||||
}
|
||||
|
||||
burstAltitude := 30000.0
|
||||
if params.BurstAltitude != nil {
|
||||
burstAltitude = *params.BurstAltitude
|
||||
}
|
||||
|
||||
descentRate := 5.0
|
||||
if params.DescentRate != nil {
|
||||
descentRate = *params.DescentRate
|
||||
}
|
||||
|
||||
floatAltitude := 0.0
|
||||
if params.FloatAltitude != nil {
|
||||
floatAltitude = *params.FloatAltitude
|
||||
}
|
||||
|
||||
// Parse custom curves if provided
|
||||
var ascentCurve, descentCurve *CustomCurve
|
||||
if params.AscentCurve != nil && *params.AscentCurve != "" {
|
||||
if curve, err := parseCustomCurve(*params.AscentCurve); err == nil {
|
||||
ascentCurve = curve
|
||||
}
|
||||
}
|
||||
if params.DescentCurve != nil && *params.DescentCurve != "" {
|
||||
if curve, err := parseCustomCurve(*params.DescentCurve); err == nil {
|
||||
descentCurve = curve
|
||||
}
|
||||
}
|
||||
|
||||
log.Ctx(ctx).Info("Starting prediction",
|
||||
zap.String("profile", profile),
|
||||
zap.Float64("lat", *params.LaunchLatitude),
|
||||
zap.Float64("lon", *params.LaunchLongitude),
|
||||
zap.Float64("alt", *params.LaunchAltitude),
|
||||
zap.Time("time", *params.LaunchDatetime),
|
||||
)
|
||||
|
||||
var allResults []ds.PredicitonResult
|
||||
|
||||
switch profile {
|
||||
case "standard_profile":
|
||||
allResults = s.standardProfile(ctx, params, ascentRate, burstAltitude, descentRate, ascentCurve, descentCurve)
|
||||
case "float_profile":
|
||||
allResults = s.floatProfile(ctx, params, ascentRate, burstAltitude, floatAltitude, descentRate, ascentCurve, descentCurve)
|
||||
case "reverse_profile":
|
||||
allResults = s.reverseProfile(ctx, params, ascentRate, burstAltitude, descentRate, ascentCurve, descentCurve)
|
||||
case "custom_profile":
|
||||
allResults = s.customProfile(ctx, params, ascentCurve, descentCurve)
|
||||
default:
|
||||
return nil, errcodes.New(400, "unsupported profile: "+profile)
|
||||
}
|
||||
|
||||
log.Ctx(ctx).Info("Prediction complete", zap.Int("total_steps", len(allResults)))
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
func (s *Service) standardProfile(ctx context.Context, params ds.PredictionParameters, ascentRate, burstAltitude, descentRate float64, ascentCurve, descentCurve *CustomCurve) []ds.PredicitonResult {
|
||||
var results []ds.PredicitonResult
|
||||
|
||||
// Stage 1: Ascent
|
||||
ascentResults := s.simulateAscent(ctx, params, ascentRate, burstAltitude, ascentCurve)
|
||||
results = append(results, ascentResults...)
|
||||
|
||||
if len(ascentResults) > 0 {
|
||||
// Get final position from ascent
|
||||
lastResult := ascentResults[len(ascentResults)-1]
|
||||
|
||||
// Stage 2: Descent
|
||||
descentParams := ds.PredictionParameters{
|
||||
LaunchLatitude: lastResult.Latitude,
|
||||
LaunchLongitude: lastResult.Longitude,
|
||||
LaunchAltitude: lastResult.Altitude,
|
||||
LaunchDatetime: lastResult.Timestamp,
|
||||
}
|
||||
|
||||
descentResults := s.simulateDescent(ctx, descentParams, descentRate, 0, descentCurve)
|
||||
results = append(results, descentResults...)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) floatProfile(ctx context.Context, params ds.PredictionParameters, ascentRate, burstAltitude, floatAltitude, descentRate float64, ascentCurve, descentCurve *CustomCurve) []ds.PredicitonResult {
|
||||
var results []ds.PredicitonResult
|
||||
|
||||
// Stage 1: Ascent to float altitude
|
||||
ascentResults := s.simulateAscent(ctx, params, ascentRate, floatAltitude, ascentCurve)
|
||||
results = append(results, ascentResults...)
|
||||
|
||||
if len(ascentResults) > 0 {
|
||||
// Stage 2: Float (simulate for some time)
|
||||
lastResult := ascentResults[len(ascentResults)-1]
|
||||
floatResults := s.simulateFloat(ctx, lastResult, 30*time.Minute) // Float for 30 minutes
|
||||
results = append(results, floatResults...)
|
||||
|
||||
if len(floatResults) > 0 {
|
||||
// Stage 3: Descent
|
||||
finalFloat := floatResults[len(floatResults)-1]
|
||||
descentParams := ds.PredictionParameters{
|
||||
LaunchLatitude: finalFloat.Latitude,
|
||||
LaunchLongitude: finalFloat.Longitude,
|
||||
LaunchAltitude: finalFloat.Altitude,
|
||||
LaunchDatetime: finalFloat.Timestamp,
|
||||
}
|
||||
|
||||
descentResults := s.simulateDescent(ctx, descentParams, descentRate, 0, descentCurve)
|
||||
results = append(results, descentResults...)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) reverseProfile(ctx context.Context, params ds.PredictionParameters, ascentRate, burstAltitude, descentRate float64, ascentCurve, descentCurve *CustomCurve) []ds.PredicitonResult {
|
||||
var results []ds.PredicitonResult
|
||||
|
||||
// Stage 1: Ascent
|
||||
ascentResults := s.simulateAscent(ctx, params, ascentRate, burstAltitude, ascentCurve)
|
||||
results = append(results, ascentResults...)
|
||||
|
||||
if len(ascentResults) > 0 {
|
||||
// Stage 2: Descent to float altitude
|
||||
lastResult := ascentResults[len(ascentResults)-1]
|
||||
descentParams := ds.PredictionParameters{
|
||||
LaunchLatitude: lastResult.Latitude,
|
||||
LaunchLongitude: lastResult.Longitude,
|
||||
LaunchAltitude: lastResult.Altitude,
|
||||
LaunchDatetime: lastResult.Timestamp,
|
||||
}
|
||||
|
||||
// Descent to float altitude (if specified)
|
||||
floatAlt := 0.0
|
||||
if params.FloatAltitude != nil {
|
||||
floatAlt = *params.FloatAltitude
|
||||
}
|
||||
|
||||
descentResults := s.simulateDescent(ctx, descentParams, descentRate, floatAlt, descentCurve)
|
||||
results = append(results, descentResults...)
|
||||
|
||||
if floatAlt > 0 && len(descentResults) > 0 {
|
||||
// Stage 3: Float
|
||||
finalDescent := descentResults[len(descentResults)-1]
|
||||
floatResults := s.simulateFloat(ctx, finalDescent, 30*time.Minute)
|
||||
results = append(results, floatResults...)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) customProfile(ctx context.Context, params ds.PredictionParameters, ascentCurve, descentCurve *CustomCurve) []ds.PredicitonResult {
|
||||
var results []ds.PredicitonResult
|
||||
|
||||
if ascentCurve != nil {
|
||||
ascentResults := s.simulateCustomAscent(ctx, params, ascentCurve)
|
||||
results = append(results, ascentResults...)
|
||||
}
|
||||
|
||||
if descentCurve != nil && len(results) > 0 {
|
||||
lastResult := results[len(results)-1]
|
||||
descentParams := ds.PredictionParameters{
|
||||
LaunchLatitude: lastResult.Latitude,
|
||||
LaunchLongitude: lastResult.Longitude,
|
||||
LaunchAltitude: lastResult.Altitude,
|
||||
LaunchDatetime: lastResult.Timestamp,
|
||||
}
|
||||
|
||||
descentResults := s.simulateCustomDescent(ctx, descentParams, descentCurve)
|
||||
results = append(results, descentResults...)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) simulateAscent(ctx context.Context, params ds.PredictionParameters, ascentRate, targetAltitude float64, customCurve *CustomCurve) []ds.PredicitonResult {
|
||||
const dt = 10.0 // simulation step in seconds
|
||||
const outputInterval = 60.0 // output every 60 seconds
|
||||
|
||||
lat := *params.LaunchLatitude
|
||||
lon := *params.LaunchLongitude
|
||||
alt := *params.LaunchAltitude
|
||||
timeCur := *params.LaunchDatetime
|
||||
|
||||
results := make([]ds.PredicitonResult, 0, 1000)
|
||||
|
||||
// Always include the initial launch point
|
||||
latCopy := lat
|
||||
lonCopy := lon
|
||||
altCopy := alt
|
||||
timeCopy := timeCur
|
||||
wind := [2]float64{0, 0}
|
||||
windU := wind[0]
|
||||
windV := wind[1]
|
||||
results = append(results, ds.PredicitonResult{
|
||||
Latitude: &latCopy,
|
||||
Longitude: &lonCopy,
|
||||
Altitude: &altCopy,
|
||||
Timestamp: &timeCopy,
|
||||
WindU: &windU,
|
||||
WindV: &windV,
|
||||
})
|
||||
|
||||
var nextOutputTime = timeCur.Add(time.Duration(outputInterval) * time.Second)
|
||||
|
||||
for alt < targetAltitude {
|
||||
wind, err := s.ExtractWind(ctx, lat, lon, alt, timeCur)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Warn("Wind extraction failed during ascent", zap.Error(err))
|
||||
break
|
||||
}
|
||||
|
||||
altRate := ascentRate
|
||||
if customCurve != nil {
|
||||
altRate = s.getCustomAltitudeRate(customCurve, alt, ascentRate)
|
||||
}
|
||||
|
||||
latDot := (wind[1] / 111320.0)
|
||||
lonDot := (wind[0] / (40075000.0 * math.Cos(lat*math.Pi/180) / 360.0))
|
||||
|
||||
lat += latDot * dt
|
||||
lon += lonDot * dt
|
||||
alt += altRate * dt
|
||||
timeCur = timeCur.Add(time.Duration(dt) * time.Second)
|
||||
|
||||
// Don't add a point if we've reached or exceeded target altitude
|
||||
if alt >= targetAltitude {
|
||||
break
|
||||
}
|
||||
|
||||
if !timeCur.Before(nextOutputTime) {
|
||||
latCopy := lat
|
||||
lonCopy := lon
|
||||
altCopy := alt
|
||||
timeCopy := timeCur
|
||||
windU := wind[0]
|
||||
windV := wind[1]
|
||||
results = append(results, ds.PredicitonResult{
|
||||
Latitude: &latCopy,
|
||||
Longitude: &lonCopy,
|
||||
Altitude: &altCopy,
|
||||
Timestamp: &timeCopy,
|
||||
WindU: &windU,
|
||||
WindV: &windV,
|
||||
})
|
||||
nextOutputTime = nextOutputTime.Add(time.Duration(outputInterval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) simulateDescent(ctx context.Context, params ds.PredictionParameters, descentRate, targetAltitude float64, customCurve *CustomCurve) []ds.PredicitonResult {
|
||||
const dt = 10.0 // simulation step in seconds
|
||||
const outputInterval = 60.0 // output every 60 seconds
|
||||
|
||||
lat := *params.LaunchLatitude
|
||||
lon := *params.LaunchLongitude
|
||||
alt := *params.LaunchAltitude
|
||||
timeCur := *params.LaunchDatetime
|
||||
|
||||
results := make([]ds.PredicitonResult, 0, 1000)
|
||||
|
||||
// Always include the initial descent point
|
||||
latCopy := lat
|
||||
lonCopy := lon
|
||||
altCopy := alt
|
||||
timeCopy := timeCur
|
||||
wind := [2]float64{0, 0}
|
||||
windU := wind[0]
|
||||
windV := wind[1]
|
||||
results = append(results, ds.PredicitonResult{
|
||||
Latitude: &latCopy,
|
||||
Longitude: &lonCopy,
|
||||
Altitude: &altCopy,
|
||||
Timestamp: &timeCopy,
|
||||
WindU: &windU,
|
||||
WindV: &windV,
|
||||
})
|
||||
|
||||
var nextOutputTime = timeCur.Add(time.Duration(outputInterval) * time.Second)
|
||||
|
||||
for alt > targetAltitude {
|
||||
wind, err := s.ExtractWind(ctx, lat, lon, alt, timeCur)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Warn("Wind extraction failed during descent", zap.Error(err))
|
||||
break
|
||||
}
|
||||
|
||||
altRate := -descentRate
|
||||
if customCurve != nil {
|
||||
altRate = -s.getCustomAltitudeRate(customCurve, alt, descentRate)
|
||||
}
|
||||
|
||||
latDot := (wind[1] / 111320.0)
|
||||
lonDot := (wind[0] / (40075000.0 * math.Cos(lat*math.Pi/180) / 360.0))
|
||||
|
||||
lat += latDot * dt
|
||||
lon += lonDot * dt
|
||||
alt += altRate * dt
|
||||
timeCur = timeCur.Add(time.Duration(dt) * time.Second)
|
||||
|
||||
// Don't add a point if we've reached or gone below target altitude
|
||||
if alt <= targetAltitude {
|
||||
break
|
||||
}
|
||||
|
||||
if !timeCur.Before(nextOutputTime) {
|
||||
latCopy := lat
|
||||
lonCopy := lon
|
||||
altCopy := alt
|
||||
timeCopy := timeCur
|
||||
windU := wind[0]
|
||||
windV := wind[1]
|
||||
results = append(results, ds.PredicitonResult{
|
||||
Latitude: &latCopy,
|
||||
Longitude: &lonCopy,
|
||||
Altitude: &altCopy,
|
||||
Timestamp: &timeCopy,
|
||||
WindU: &windU,
|
||||
WindV: &windV,
|
||||
})
|
||||
nextOutputTime = nextOutputTime.Add(time.Duration(outputInterval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) simulateFloat(ctx context.Context, startResult ds.PredicitonResult, duration time.Duration) []ds.PredicitonResult {
|
||||
const dt = 10.0 // simulation step in seconds
|
||||
const outputInterval = 60.0 // output every 60 seconds
|
||||
|
||||
lat := *startResult.Latitude
|
||||
lon := *startResult.Longitude
|
||||
alt := *startResult.Altitude
|
||||
timeCur := *startResult.Timestamp
|
||||
endTime := timeCur.Add(duration)
|
||||
|
||||
results := make([]ds.PredicitonResult, 0, 1000)
|
||||
|
||||
// Always include the initial float point
|
||||
latCopy := lat
|
||||
lonCopy := lon
|
||||
altCopy := alt
|
||||
timeCopy := timeCur
|
||||
wind := [2]float64{0, 0}
|
||||
windU := wind[0]
|
||||
windV := wind[1]
|
||||
results = append(results, ds.PredicitonResult{
|
||||
Latitude: &latCopy,
|
||||
Longitude: &lonCopy,
|
||||
Altitude: &altCopy,
|
||||
Timestamp: &timeCopy,
|
||||
WindU: &windU,
|
||||
WindV: &windV,
|
||||
})
|
||||
|
||||
var nextOutputTime = timeCur.Add(time.Duration(outputInterval) * time.Second)
|
||||
|
||||
for timeCur.Before(endTime) {
|
||||
wind, err := s.ExtractWind(ctx, lat, lon, alt, timeCur)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Warn("Wind extraction failed during float", zap.Error(err))
|
||||
break
|
||||
}
|
||||
|
||||
latDot := (wind[1] / 111320.0)
|
||||
lonDot := (wind[0] / (40075000.0 * math.Cos(lat*math.Pi/180) / 360.0))
|
||||
|
||||
lat += latDot * dt
|
||||
lon += lonDot * dt
|
||||
// alt remains constant during float
|
||||
timeCur = timeCur.Add(time.Duration(dt) * time.Second)
|
||||
|
||||
if !timeCur.Before(nextOutputTime) {
|
||||
latCopy := lat
|
||||
lonCopy := lon
|
||||
altCopy := alt
|
||||
timeCopy := timeCur
|
||||
windU := wind[0]
|
||||
windV := wind[1]
|
||||
results = append(results, ds.PredicitonResult{
|
||||
Latitude: &latCopy,
|
||||
Longitude: &lonCopy,
|
||||
Altitude: &altCopy,
|
||||
Timestamp: &timeCopy,
|
||||
WindU: &windU,
|
||||
WindV: &windV,
|
||||
})
|
||||
nextOutputTime = nextOutputTime.Add(time.Duration(outputInterval) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) simulateCustomAscent(ctx context.Context, params ds.PredictionParameters, curve *CustomCurve) []ds.PredicitonResult {
|
||||
// Implementation for custom ascent curve
|
||||
// This would interpolate the altitude rate from the custom curve
|
||||
return s.simulateAscent(ctx, params, 5.0, 30000.0, curve)
|
||||
}
|
||||
|
||||
func (s *Service) simulateCustomDescent(ctx context.Context, params ds.PredictionParameters, curve *CustomCurve) []ds.PredicitonResult {
|
||||
// Implementation for custom descent curve
|
||||
// This would interpolate the altitude rate from the custom curve
|
||||
return s.simulateDescent(ctx, params, 5.0, 0.0, curve)
|
||||
}
|
||||
|
||||
func (s *Service) getCustomAltitudeRate(curve *CustomCurve, currentAltitude, defaultRate float64) float64 {
|
||||
if curve == nil || len(curve.Altitude) < 2 {
|
||||
return defaultRate
|
||||
}
|
||||
|
||||
// Find the two points in the curve that bracket the current altitude
|
||||
for i := 0; i < len(curve.Altitude)-1; i++ {
|
||||
if curve.Altitude[i] <= currentAltitude && currentAltitude <= curve.Altitude[i+1] {
|
||||
// Linear interpolation
|
||||
alt1, alt2 := curve.Altitude[i], curve.Altitude[i+1]
|
||||
time1, time2 := curve.Time[i], curve.Time[i+1]
|
||||
|
||||
if alt2 == alt1 {
|
||||
return defaultRate
|
||||
}
|
||||
|
||||
// Calculate rate (change in altitude per second)
|
||||
if time2 > time1 {
|
||||
return (alt2 - alt1) / (time2 - time1)
|
||||
}
|
||||
return defaultRate
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRate
|
||||
}
|
||||
|
||||
func parseCustomCurve(base64Data string) (*CustomCurve, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: Implement full prediction logic
|
||||
result := ds.PredicitonResult{
|
||||
Latitude: params.LaunchLatitude,
|
||||
Longitude: params.LaunchLongitude,
|
||||
Altitude: params.LaunchAltitude,
|
||||
Timestamp: params.LaunchDatetime,
|
||||
WindU: wind[0],
|
||||
WindV: wind[1],
|
||||
var curve CustomCurve
|
||||
if err := json.Unmarshal(data, &curve); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []ds.PredicitonResult{result}, nil
|
||||
return &curve, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,20 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/log"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg *Config
|
||||
redis Redis
|
||||
grib Grib
|
||||
logger *zap.Logger
|
||||
cfg *Config
|
||||
redis Redis
|
||||
grib Grib
|
||||
}
|
||||
|
||||
func New(cfg *Config, gribService Grib, redisService Redis, logger *zap.Logger) (*Service, error) {
|
||||
func New(cfg *Config, gribService Grib, redisService Redis) (*Service, error) {
|
||||
svc := &Service{
|
||||
cfg: cfg,
|
||||
redis: redisService,
|
||||
grib: gribService,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
redis: redisService,
|
||||
grib: gribService,
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
|
|
@ -42,12 +40,12 @@ func (s *Service) Update(ctx context.Context) error {
|
|||
|
||||
// Start starts the service
|
||||
func (s *Service) Start() {
|
||||
s.logger.Info("service started")
|
||||
log.Ctx(context.Background()).Info("service started")
|
||||
}
|
||||
|
||||
// Stop stops the service
|
||||
func (s *Service) Stop() {
|
||||
s.logger.Info("service stopped")
|
||||
log.Ctx(context.Background()).Info("service stopped")
|
||||
}
|
||||
|
||||
// Close closes the service and releases resources
|
||||
|
|
@ -55,3 +53,12 @@ func (s *Service) Close() error {
|
|||
s.Stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetGribStatus(ctx context.Context) (ready bool, lastUpdate time.Time, isFresh bool, errMsg string) {
|
||||
if gribStatus, ok := s.grib.(interface {
|
||||
GetStatus() (ready bool, lastUpdate time.Time, isFresh bool, errMsg string)
|
||||
}); ok {
|
||||
return gribStatus.GetStatus()
|
||||
}
|
||||
return false, time.Time{}, false, "grib service does not implement GetStatus"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import (
|
|||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Logging(logger *zap.Logger) middleware.Middleware {
|
||||
func Logging() middleware.Middleware {
|
||||
return func(req middleware.Request, next func(req middleware.Request) (middleware.Response, error)) (middleware.Response, error) {
|
||||
lg := logger.With(
|
||||
lg := log.Ctx(req.Context).With(
|
||||
zap.String("operationId", req.OperationID),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package rest
|
||||
|
||||
import (
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/errcodes"
|
||||
env "github.com/caarlos0/env/v11"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string `env:"HOST" envDefault:"0.0.0.0"`
|
||||
Port int `env:"PORT" envDefault:"8080"`
|
||||
|
|
@ -7,3 +12,13 @@ type Config struct {
|
|||
WriteTimeout string `env:"WRITE_TIMEOUT" envDefault:"30s"`
|
||||
IdleTimeout string `env:"IDLE_TIMEOUT" envDefault:"60s"`
|
||||
}
|
||||
|
||||
func NewConfig() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
if err := env.ParseWithOptions(cfg, env.Options{
|
||||
PrefixTagName: "GSN_PREDICTOR_REST_",
|
||||
}); err != nil {
|
||||
return nil, errcodes.Wrap(err, "failed to parse REST config")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ package handler
|
|||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/ds"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
UpdateWeatherData(ctx context.Context) error
|
||||
ExtractWind(ctx context.Context, lat, lon, alt float64, ts time.Time) ([2]float64, error)
|
||||
PerformPrediction(ctx context.Context, params ds.PredictionParameters) ([]ds.PredicitonResult, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package handler
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/ds"
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/errcodes"
|
||||
api "git.intra.yksa.space/gsn/predictor/pkg/rest"
|
||||
)
|
||||
|
|
@ -23,7 +25,115 @@ func New(svc Service) *Handler {
|
|||
}
|
||||
|
||||
func (h *Handler) PerformPrediction(ctx context.Context, req api.OptPredictionParameters, params api.PerformPredictionParams) (*api.PredictionResult, error) {
|
||||
return nil, errcodes.New(http.StatusNotImplemented, "not implemented", "please wait")
|
||||
internalParams := ds.ConvertOptPredictionParameters(req)
|
||||
if internalParams == nil {
|
||||
return nil, errcodes.New(http.StatusBadRequest, "invalid or missing parameters")
|
||||
}
|
||||
results, err := h.svc.PerformPrediction(ctx, *internalParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, errcodes.New(http.StatusInternalServerError, "no prediction results")
|
||||
}
|
||||
|
||||
// Group results into stages (ascent and descent)
|
||||
stages := h.groupResultsIntoStages(results)
|
||||
|
||||
// Map to OpenAPI schema
|
||||
var predictionItems []api.PredictionResultPredictionItem
|
||||
|
||||
for _, stage := range stages {
|
||||
var trajectory []api.PredictionResultPredictionItemTrajectoryItem
|
||||
|
||||
for _, result := range stage.Results {
|
||||
traj := api.PredictionResultPredictionItemTrajectoryItem{
|
||||
Datetime: *result.Timestamp,
|
||||
Latitude: *result.Latitude,
|
||||
Longitude: *result.Longitude,
|
||||
Altitude: *result.Altitude,
|
||||
}
|
||||
trajectory = append(trajectory, traj)
|
||||
}
|
||||
|
||||
item := api.PredictionResultPredictionItem{
|
||||
Stage: stage.Stage,
|
||||
Trajectory: trajectory,
|
||||
}
|
||||
predictionItems = append(predictionItems, item)
|
||||
}
|
||||
|
||||
metadata := api.PredictionResultMetadata{
|
||||
StartDatetime: *results[0].Timestamp,
|
||||
CompleteDatetime: *results[len(results)-1].Timestamp,
|
||||
}
|
||||
|
||||
resp := &api.PredictionResult{
|
||||
Metadata: metadata,
|
||||
Prediction: predictionItems,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// StageResult represents a stage with its results
|
||||
type StageResult struct {
|
||||
Stage api.PredictionResultPredictionItemStage
|
||||
Results []ds.PredicitonResult
|
||||
}
|
||||
|
||||
// groupResultsIntoStages groups the prediction results into ascent and descent stages
|
||||
func (h *Handler) groupResultsIntoStages(results []ds.PredicitonResult) []StageResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var stages []StageResult
|
||||
var currentStage []ds.PredicitonResult
|
||||
var currentStageType api.PredictionResultPredictionItemStage
|
||||
|
||||
// Determine if we're in ascent or descent based on altitude changes
|
||||
prevAlt := *results[0].Altitude
|
||||
currentStage = append(currentStage, results[0])
|
||||
currentStageType = api.PredictionResultPredictionItemStageAscent
|
||||
|
||||
for i := 1; i < len(results); i++ {
|
||||
result := results[i]
|
||||
currentAlt := *result.Altitude
|
||||
|
||||
// Determine if we're still in the same stage
|
||||
var stageType api.PredictionResultPredictionItemStage
|
||||
if currentAlt > prevAlt {
|
||||
stageType = api.PredictionResultPredictionItemStageAscent
|
||||
} else if currentAlt < prevAlt {
|
||||
stageType = api.PredictionResultPredictionItemStageDescent
|
||||
} else {
|
||||
// Same altitude - continue with current stage
|
||||
stageType = currentStageType
|
||||
}
|
||||
|
||||
// If stage type changed, finalize current stage and start new one
|
||||
if stageType != currentStageType && len(currentStage) > 0 {
|
||||
stages = append(stages, StageResult{
|
||||
Stage: currentStageType,
|
||||
Results: currentStage,
|
||||
})
|
||||
currentStage = nil
|
||||
currentStageType = stageType
|
||||
}
|
||||
|
||||
currentStage = append(currentStage, result)
|
||||
prevAlt = currentAlt
|
||||
}
|
||||
|
||||
// Add the final stage
|
||||
if len(currentStage) > 0 {
|
||||
stages = append(stages, StageResult{
|
||||
Stage: currentStageType,
|
||||
Results: currentStage,
|
||||
})
|
||||
}
|
||||
|
||||
return stages
|
||||
}
|
||||
|
||||
func (h *Handler) NewError(ctx context.Context, err error) *api.ErrorStatusCode {
|
||||
|
|
@ -50,3 +160,35 @@ func (h *Handler) NewError(ctx context.Context, err error) *api.ErrorStatusCode
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ReadinessCheck(ctx context.Context) (*api.ReadinessResponse, error) {
|
||||
status := api.ReadinessResponseStatusNotReady
|
||||
var lastUpdate time.Time
|
||||
var isFresh bool
|
||||
var errMsg string
|
||||
|
||||
if s, ok := h.svc.(interface {
|
||||
GetGribStatus(ctx context.Context) (ready bool, lastUpdate time.Time, isFresh bool, errMsg string)
|
||||
}); ok {
|
||||
ready, lu, fresh, em := s.GetGribStatus(ctx)
|
||||
lastUpdate = lu
|
||||
isFresh = fresh
|
||||
errMsg = em
|
||||
if ready {
|
||||
status = api.ReadinessResponseStatusOk
|
||||
} else if em != "" {
|
||||
status = api.ReadinessResponseStatusError
|
||||
}
|
||||
} else {
|
||||
errMsg = "service does not implement GetGribStatus"
|
||||
status = api.ReadinessResponseStatusError
|
||||
}
|
||||
|
||||
resp := &api.ReadinessResponse{
|
||||
Status: status,
|
||||
IsFresh: api.NewOptBool(isFresh),
|
||||
LastUpdate: api.NewOptDateTime(lastUpdate),
|
||||
ErrorMessage: api.NewOptString(errMsg),
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,37 @@
|
|||
package rest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.intra.yksa.space/gsn/predictor/internal/pkg/log"
|
||||
"git.intra.yksa.space/gsn/predictor/internal/transport/middleware"
|
||||
handler "git.intra.yksa.space/gsn/predictor/internal/transport/rest/handler"
|
||||
api "git.intra.yksa.space/gsn/predictor/pkg/rest"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Transport struct {
|
||||
lg *zap.Logger
|
||||
cfg *Config
|
||||
srv *api.Server
|
||||
cfg *Config
|
||||
srv *api.Server
|
||||
handler *handler.Handler
|
||||
}
|
||||
|
||||
func New(lg *zap.Logger, handler *handler.Handler, cfg *Config) (*Transport, error) {
|
||||
srv, err := api.NewServer(handler, api.WithMiddleware(middleware.Logging(lg)))
|
||||
func New(handler *handler.Handler, cfg *Config) (*Transport, error) {
|
||||
srv, err := api.NewServer(handler, api.WithMiddleware(middleware.Logging()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Transport{
|
||||
lg: lg,
|
||||
srv: srv,
|
||||
cfg: cfg,
|
||||
srv: srv,
|
||||
cfg: cfg,
|
||||
handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Transport) Run() {
|
||||
t.lg.Info("started")
|
||||
log.Ctx(context.Background()).Info("started")
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", t.cfg.Port), t.srv); err != nil {
|
||||
panic(err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue