package propagator import ( "fmt" "math" "sort" "sync" "time" "SatMaster/backend/tle" "github.com/akhenakh/sgp4" ) const ( EarthRadius = 6371.0 // km RadToDeg = 180.0 / math.Pi ) // SatPosition holds the computed position of a satellite at a given moment. type SatPosition struct { Name string `json:"name"` Latitude float64 `json:"lat"` Longitude float64 `json:"lon"` Altitude float64 `json:"alt"` // km Azimuth float64 `json:"az"` // degrees 0=N Elevation float64 `json:"el"` // degrees above horizon Range float64 `json:"range"` // km from observer RangeRate float64 `json:"rangeRate"` // km/s positive=receding Footprint float64 `json:"footprint"` // km radius on ground } // Pass represents one overhead pass of a satellite. type Pass struct { SatName string `json:"satName"` AOS time.Time `json:"aos"` LOS time.Time `json:"los"` MaxEl float64 `json:"maxEl"` MaxElTime time.Time `json:"maxElTime"` AosAz float64 `json:"aosAz"` LosAz float64 `json:"losAz"` MaxElAz float64 `json:"maxElAz"` Duration float64 `json:"duration"` // seconds Points []PassPoint `json:"points"` } // PassPoint is one az/el sample in a pass track. type PassPoint struct { Time time.Time `json:"t"` Az float64 `json:"az"` El float64 `json:"el"` } // Observer holds the QTH location. type Observer struct { Lat float64 // degrees Lon float64 // degrees Alt float64 // meters above sea level } // Engine propagates satellite positions using SGP4. type Engine struct { mu sync.RWMutex observer Observer } func NewEngine() *Engine { return &Engine{ observer: Observer{Lat: 45.75, Lon: 4.85, Alt: 200}, // Default: Lyon area } } func (e *Engine) SetObserver(lat, lon, altM float64) { e.mu.Lock() defer e.mu.Unlock() e.observer = Observer{Lat: lat, Lon: lon, Alt: altM} } func (e *Engine) ObserverPosition() Observer { e.mu.RLock() defer e.mu.RUnlock() return e.observer } // Position computes the current position of one satellite. func (e *Engine) Position(sat *tle.Satellite, t time.Time) *SatPosition { e.mu.RLock() obs := e.observer e.mu.RUnlock() return computePosition(sat, obs, t) } // AllPositions computes positions for all loaded satellites. func (e *Engine) AllPositions(sats []*tle.Satellite, t time.Time) []SatPosition { e.mu.RLock() obs := e.observer e.mu.RUnlock() result := make([]SatPosition, 0, len(sats)) for _, sat := range sats { if pos := computePosition(sat, obs, t); pos != nil { result = append(result, *pos) } } return result } // ComputePasses predicts upcoming passes over `hours` hours. func (e *Engine) ComputePasses(sat *tle.Satellite, start time.Time, hours float64) []Pass { e.mu.RLock() obs := e.observer e.mu.RUnlock() var passes []Pass end := start.Add(time.Duration(float64(time.Hour) * hours)) step := 10 * time.Second t := start var inPass bool var cur *Pass for t.Before(end) { pos := computePosition(sat, obs, t) if pos == nil { t = t.Add(step) continue } if pos.Elevation > 0 && !inPass { inPass = true aosT := bisectAOS(sat, obs, t.Add(-step), t) aosPos := computePosition(sat, obs, aosT) if aosPos == nil { aosPos = pos } cur = &Pass{ SatName: sat.Name, AOS: aosT, AosAz: aosPos.Azimuth, Points: []PassPoint{}, } } if inPass && cur != nil { cur.Points = append(cur.Points, PassPoint{Time: t, Az: pos.Azimuth, El: pos.Elevation}) if pos.Elevation > cur.MaxEl { cur.MaxEl = pos.Elevation cur.MaxElTime = t cur.MaxElAz = pos.Azimuth } } if pos.Elevation <= 0 && inPass { inPass = false if cur != nil { losT := bisectLOS(sat, obs, t.Add(-step), t) losPos := computePosition(sat, obs, losT) if losPos == nil { losPos = pos } cur.LOS = losT cur.LosAz = losPos.Azimuth cur.Duration = losT.Sub(cur.AOS).Seconds() if cur.MaxEl >= 1.0 { passes = append(passes, *cur) } cur = nil } } if pos.Elevation < -15 { step = 30 * time.Second } else { step = 10 * time.Second } t = t.Add(step) } sort.Slice(passes, func(i, j int) bool { return passes[i].AOS.Before(passes[j].AOS) }) return passes } // ─── Core SGP4 computation ──────────────────────────────────────────────────── // parsedTLE caches the orbital elements for a satellite TLE. // akhenakh/sgp4 ParseTLE accepts the 3-line TLE as a single string. func makeTLEString(sat *tle.Satellite) string { return fmt.Sprintf("%s\n%s\n%s", sat.Name, sat.TLE1, sat.TLE2) } func computePosition(sat *tle.Satellite, obs Observer, t time.Time) *SatPosition { // Parse TLE — akhenakh/sgp4 accepts the 3-line block as one string tleObj, err := sgp4.ParseTLE(makeTLEString(sat)) if err != nil { return nil } // Propagate to time t → ECI state with geodetic fields eciState, err := tleObj.FindPositionAtTime(t) if err != nil { return nil } // Convert ECI to geodetic (lat deg, lon deg, alt km) lat, lon, altKm := eciState.ToGeodetic() // Build observer location location := &sgp4.Location{ Latitude: obs.Lat, // degrees Longitude: obs.Lon, // degrees Altitude: obs.Alt, // meters } // Build state vector for look angle calculation sv := &sgp4.StateVector{ X: eciState.Position.X, Y: eciState.Position.Y, Z: eciState.Position.Z, VX: eciState.Velocity.X, VY: eciState.Velocity.Y, VZ: eciState.Velocity.Z, } // GetLookAngle uses eciState.DateTime (the propagated time) not t observation, err := sv.GetLookAngle(location, eciState.DateTime) if err != nil { return nil } la := observation.LookAngles footprint := EarthRadius * math.Acos(EarthRadius/(EarthRadius+altKm)) // Compute range rate by finite difference (2s) - library value is unreliable rangeRate := finiteRangeRate(tleObj, location, t) return &SatPosition{ Name: sat.Name, Latitude: lat, Longitude: lon, Altitude: altKm, Azimuth: la.Azimuth, Elevation: la.Elevation, Range: la.Range, RangeRate: rangeRate, Footprint: footprint, } } // finiteRangeRate computes range rate (km/s) by finite difference over 2 seconds. // Positive = satellite receding, negative = approaching. func finiteRangeRate(tleObj *sgp4.TLE, loc *sgp4.Location, t time.Time) float64 { dt := 2.0 // seconds t2 := t.Add(time.Duration(dt * float64(time.Second))) e1, err1 := tleObj.FindPositionAtTime(t) e2, err2 := tleObj.FindPositionAtTime(t2) if err1 != nil || err2 != nil { return 0 } sv1 := &sgp4.StateVector{X: e1.Position.X, Y: e1.Position.Y, Z: e1.Position.Z, VX: e1.Velocity.X, VY: e1.Velocity.Y, VZ: e1.Velocity.Z} sv2 := &sgp4.StateVector{X: e2.Position.X, Y: e2.Position.Y, Z: e2.Position.Z, VX: e2.Velocity.X, VY: e2.Velocity.Y, VZ: e2.Velocity.Z} obs1, err1 := sv1.GetLookAngle(loc, e1.DateTime) obs2, err2 := sv2.GetLookAngle(loc, e2.DateTime) if err1 != nil || err2 != nil { return 0 } return (obs2.LookAngles.Range - obs1.LookAngles.Range) / dt } // bisectAOS finds exact AOS time (precision: 1 second). func bisectAOS(sat *tle.Satellite, obs Observer, lo, hi time.Time) time.Time { for hi.Sub(lo) > time.Second { mid := lo.Add(hi.Sub(lo) / 2) pos := computePosition(sat, obs, mid) if pos != nil && pos.Elevation > 0 { hi = mid } else { lo = mid } } return hi } // bisectLOS finds exact LOS time (precision: 1 second). func bisectLOS(sat *tle.Satellite, obs Observer, lo, hi time.Time) time.Time { for hi.Sub(lo) > time.Second { mid := lo.Add(hi.Sub(lo) / 2) pos := computePosition(sat, obs, mid) if pos != nil && pos.Elevation > 0 { lo = mid } else { hi = mid } } return lo } // GroundtrackPoint is a lat/lon position at a given time. type GroundtrackPoint struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` Time time.Time `json:"t"` } // ComputeGroundtrack returns the future orbit track for the next `minutes` minutes. // Points every 30 seconds. Handles the antimeridian by splitting segments. func (e *Engine) ComputeGroundtrack(sat *tle.Satellite, start time.Time, minutes float64) []GroundtrackPoint { e.mu.RLock() defer e.mu.RUnlock() var points []GroundtrackPoint end := start.Add(time.Duration(float64(time.Minute) * minutes)) step := 30 * time.Second // Parse TLE once outside the loop for efficiency tleObj, err := sgp4.ParseTLE(fmt.Sprintf("%s\n%s\n%s", sat.Name, sat.TLE1, sat.TLE2)) if err != nil { return points } for t := start; t.Before(end); t = t.Add(step) { eciState, err := tleObj.FindPositionAtTime(t) if err != nil { continue } lat, lon, _ := eciState.ToGeodetic() points = append(points, GroundtrackPoint{Lat: lat, Lon: lon, Time: t}) } return points }