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) { // 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 } var curve CustomCurve if err := json.Unmarshal(data, &curve); err != nil { return nil, err } return &curve, nil }