feat(orbitviewer): add satellite orbit viewer with 2D track, 10-day pass prediction, and AR sky view
Build and Push Docker Image / build (push) Successful in 2m31s
Build and Push Docker Image / build (push) Successful in 2m31s
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
package orbitviewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TrackPoint struct {
|
||||
Timestamp int64 `json:"timestamp"` // Unix timestamp in seconds
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Altitude float64 `json:"altitude"`
|
||||
Azimuth *float64 `json:"azimuth,omitempty"`
|
||||
Elevation *float64 `json:"elevation,omitempty"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
SatelliteID string `json:"satellite_id"`
|
||||
SatelliteName string `json:"satellite_name"`
|
||||
NoradID int `json:"norad_id"`
|
||||
CurrentTime time.Time `json:"current_time"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
AltitudeKm float64 `json:"altitude_km"`
|
||||
VelocityKmS float64 `json:"velocity_km_s"`
|
||||
FootprintKm float64 `json:"footprint_km"`
|
||||
IsSunlit bool `json:"is_sunlit"`
|
||||
SubSolarPoint Geodetic `json:"sub_solar_point"`
|
||||
PastTrack []TrackPoint `json:"past_track"`
|
||||
FutureTrack []TrackPoint `json:"future_track"`
|
||||
OrbitalRing []TrackPoint `json:"orbital_ring,omitempty"`
|
||||
ObserverLook *LookAngles `json:"observer_look,omitempty"`
|
||||
}
|
||||
|
||||
type TrackRequest struct {
|
||||
SatelliteID string `json:"satellite_id"`
|
||||
CustomTLE *TLEInput `json:"custom_tle,omitempty"`
|
||||
Observer *Geodetic `json:"observer,omitempty"`
|
||||
Timestamp *string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
type TLEInput struct {
|
||||
Name string `json:"name"`
|
||||
Line1 string `json:"line1"`
|
||||
Line2 string `json:"line2"`
|
||||
}
|
||||
|
||||
type PassesRequest struct {
|
||||
SatelliteID string `json:"satellite_id"`
|
||||
CustomTLE *TLEInput `json:"custom_tle,omitempty"`
|
||||
Observer Geodetic `json:"observer"`
|
||||
Days int `json:"days"`
|
||||
MinElevationDeg float64 `json:"min_elevation_deg"`
|
||||
VisibleOnly bool `json:"visible_only"`
|
||||
}
|
||||
|
||||
type PositionRequest struct {
|
||||
SatelliteID string `json:"satellite_id"`
|
||||
CustomTLE *TLEInput `json:"custom_tle,omitempty"`
|
||||
Observer Geodetic `json:"observer"`
|
||||
Timestamp *string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
func getSGP4Model(satID string, custom *TLEInput) (*SGP4Model, *SatelliteCatalogEntry, error) {
|
||||
if custom != nil && custom.Line1 != "" && custom.Line2 != "" {
|
||||
tle, err := ParseTLE(custom.Name, custom.Line1, custom.Line2)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid custom TLE: %w", err)
|
||||
}
|
||||
model, err := NewSGP4Model(tle)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return model, &SatelliteCatalogEntry{
|
||||
ID: "custom",
|
||||
Name: tle.Name,
|
||||
NoradID: tle.NoradID,
|
||||
StandardMag: 3.0,
|
||||
TLE: tle,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if satID == "" {
|
||||
satID = "iss"
|
||||
}
|
||||
|
||||
entry, ok := DefaultCatalog.Get(satID)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("satellite %s not found in catalog", satID)
|
||||
}
|
||||
|
||||
model, err := NewSGP4Model(entry.TLE)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return model, entry, nil
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.RouterGroup) {
|
||||
// List satellites
|
||||
r.GET("/satellites", func(c *gin.Context) {
|
||||
entries := DefaultCatalog.GetAll()
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].NoradID < entries[j].NoradID
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"satellites": entries})
|
||||
})
|
||||
|
||||
// Refresh satellite TLE from CelesTrak
|
||||
r.POST("/refresh", func(c *gin.Context) {
|
||||
var req struct {
|
||||
SatelliteID string `json:"satellite_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.SatelliteID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "satellite_id is required"})
|
||||
return
|
||||
}
|
||||
if err := DefaultCatalog.RefreshFromCelesTrak(req.SatelliteID); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entry, _ := DefaultCatalog.Get(req.SatelliteID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "refreshed successfully", "satellite": entry})
|
||||
})
|
||||
|
||||
// Add custom TLE
|
||||
r.POST("/custom", func(c *gin.Context) {
|
||||
var req TLEInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
entry, err := DefaultCatalog.AddCustomTLE(req.Name, req.Line1, req.Line2)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"satellite": entry})
|
||||
})
|
||||
|
||||
// Ground track and current telemetry
|
||||
r.POST("/track", func(c *gin.Context) {
|
||||
var req TrackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// default to ISS
|
||||
req.SatelliteID = "iss"
|
||||
}
|
||||
|
||||
model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
t := time.Now().UTC()
|
||||
if req.Timestamp != nil && *req.Timestamp != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, *req.Timestamp); err == nil {
|
||||
t = parsed.UTC()
|
||||
}
|
||||
}
|
||||
|
||||
geo, vel, err := model.GeodeticPosition(t)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
posECEF, _, _ := model.PositionECEF(t)
|
||||
jd := JulianDate(t)
|
||||
isSunlit := IsSatelliteIlluminated(posECEF, jd)
|
||||
|
||||
velKmS := math.Sqrt(vel.X*vel.X + vel.Y*vel.Y + vel.Z*vel.Z)
|
||||
footprintKm := FootprintRadiusKm(geo.Altitude)
|
||||
|
||||
// Subsolar point
|
||||
sunECEF := SunPositionECEF(jd)
|
||||
subsolar := ECEFtoGeodetic(sunECEF)
|
||||
|
||||
currTrackPt := TrackPoint{
|
||||
Timestamp: t.Unix(),
|
||||
Latitude: math.Round(geo.Latitude*10000) / 10000,
|
||||
Longitude: math.Round(geo.Longitude*10000) / 10000,
|
||||
Altitude: math.Round(geo.Altitude*10) / 10,
|
||||
}
|
||||
if req.Observer != nil {
|
||||
l := CalculateLookAngles(*req.Observer, posECEF)
|
||||
az := math.Round(l.Azimuth*10) / 10
|
||||
el := math.Round(l.Elevation*10) / 10
|
||||
currTrackPt.Azimuth = &az
|
||||
currTrackPt.Elevation = &el
|
||||
}
|
||||
|
||||
// Past track (-45 min to 0, every 30 seconds)
|
||||
var pastTrack []TrackPoint
|
||||
for s := -45 * 60; s < 0; s += 30 {
|
||||
ptTime := t.Add(time.Duration(s) * time.Second)
|
||||
pGeo, _, err := model.GeodeticPosition(ptTime)
|
||||
if err == nil {
|
||||
pt := TrackPoint{
|
||||
Timestamp: ptTime.Unix(),
|
||||
Latitude: math.Round(pGeo.Latitude*1000) / 1000,
|
||||
Longitude: math.Round(pGeo.Longitude*1000) / 1000,
|
||||
Altitude: math.Round(pGeo.Altitude*10) / 10,
|
||||
}
|
||||
if req.Observer != nil {
|
||||
pECEF, _, errECEF := model.PositionECEF(ptTime)
|
||||
if errECEF == nil {
|
||||
l := CalculateLookAngles(*req.Observer, pECEF)
|
||||
az := math.Round(l.Azimuth*10) / 10
|
||||
el := math.Round(l.Elevation*10) / 10
|
||||
pt.Azimuth = &az
|
||||
pt.Elevation = &el
|
||||
}
|
||||
}
|
||||
pastTrack = append(pastTrack, pt)
|
||||
}
|
||||
}
|
||||
pastTrack = append(pastTrack, currTrackPt)
|
||||
|
||||
// Future track (0 to +95 min, every 30 seconds)
|
||||
var futureTrack []TrackPoint
|
||||
futureTrack = append(futureTrack, currTrackPt)
|
||||
for s := 30; s <= 95*60; s += 30 {
|
||||
ptTime := t.Add(time.Duration(s) * time.Second)
|
||||
pGeo, _, err := model.GeodeticPosition(ptTime)
|
||||
if err == nil {
|
||||
pt := TrackPoint{
|
||||
Timestamp: ptTime.Unix(),
|
||||
Latitude: math.Round(pGeo.Latitude*1000) / 1000,
|
||||
Longitude: math.Round(pGeo.Longitude*1000) / 1000,
|
||||
Altitude: math.Round(pGeo.Altitude*10) / 10,
|
||||
}
|
||||
if req.Observer != nil {
|
||||
pECEF, _, errECEF := model.PositionECEF(ptTime)
|
||||
if errECEF == nil {
|
||||
l := CalculateLookAngles(*req.Observer, pECEF)
|
||||
az := math.Round(l.Azimuth*10) / 10
|
||||
el := math.Round(l.Elevation*10) / 10
|
||||
pt.Azimuth = &az
|
||||
pt.Elevation = &el
|
||||
}
|
||||
}
|
||||
futureTrack = append(futureTrack, pt)
|
||||
}
|
||||
}
|
||||
|
||||
// Instantaneous 3D orbital ring at timestamp t (fixed inertial frame relative to observer)
|
||||
var orbitalRing []TrackPoint
|
||||
periodMin := 92.8
|
||||
if model.TLE.MeanMotionRevDay > 0 {
|
||||
periodMin = 1440.0 / model.TLE.MeanMotionRevDay
|
||||
}
|
||||
periodSec := periodMin * 60.0
|
||||
steps := 120
|
||||
stepSec := periodSec / float64(steps)
|
||||
|
||||
for i := 0; i <= steps; i++ {
|
||||
tau := t.Add(time.Duration(float64(i)*stepSec) * time.Second)
|
||||
posTEME, _, errTEME := model.PropagateTime(tau)
|
||||
if errTEME == nil {
|
||||
// Freeze Earth at time t (jd) so the ring is a true static circle in ECEF/topocentric frame
|
||||
pRingECEF := TEMEtoECEF(posTEME, jd)
|
||||
geoRing := ECEFtoGeodetic(pRingECEF)
|
||||
pt := TrackPoint{
|
||||
Timestamp: tau.Unix(),
|
||||
Latitude: math.Round(geoRing.Latitude*1000) / 1000,
|
||||
Longitude: math.Round(geoRing.Longitude*1000) / 1000,
|
||||
Altitude: math.Round(geoRing.Altitude*10) / 10,
|
||||
}
|
||||
if req.Observer != nil {
|
||||
l := CalculateLookAngles(*req.Observer, pRingECEF)
|
||||
az := math.Round(l.Azimuth*10) / 10
|
||||
el := math.Round(l.Elevation*10) / 10
|
||||
pt.Azimuth = &az
|
||||
pt.Elevation = &el
|
||||
}
|
||||
orbitalRing = append(orbitalRing, pt)
|
||||
}
|
||||
}
|
||||
|
||||
var look *LookAngles
|
||||
if req.Observer != nil {
|
||||
l := CalculateLookAngles(*req.Observer, posECEF)
|
||||
look = &LookAngles{
|
||||
Azimuth: math.Round(l.Azimuth*10) / 10,
|
||||
Elevation: math.Round(l.Elevation*10) / 10,
|
||||
Range: math.Round(l.Range*10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TrackResponse{
|
||||
SatelliteID: entry.ID,
|
||||
SatelliteName: entry.Name,
|
||||
NoradID: entry.NoradID,
|
||||
CurrentTime: t,
|
||||
Latitude: math.Round(geo.Latitude*10000) / 10000,
|
||||
Longitude: math.Round(geo.Longitude*10000) / 10000,
|
||||
AltitudeKm: math.Round(geo.Altitude*10) / 10,
|
||||
VelocityKmS: math.Round(velKmS*100) / 100,
|
||||
FootprintKm: math.Round(footprintKm),
|
||||
IsSunlit: isSunlit,
|
||||
SubSolarPoint: Geodetic{
|
||||
Latitude: math.Round(subsolar.Latitude*100) / 100,
|
||||
Longitude: math.Round(subsolar.Longitude*100) / 100,
|
||||
Altitude: 0,
|
||||
},
|
||||
PastTrack: pastTrack,
|
||||
FutureTrack: futureTrack,
|
||||
OrbitalRing: orbitalRing,
|
||||
ObserverLook: look,
|
||||
})
|
||||
})
|
||||
|
||||
// Pass prediction
|
||||
r.POST("/passes", func(c *gin.Context) {
|
||||
var req PassesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Days <= 0 || req.Days > 14 {
|
||||
req.Days = 10
|
||||
}
|
||||
if req.MinElevationDeg <= 0 {
|
||||
req.MinElevationDeg = 10.0
|
||||
}
|
||||
|
||||
allPasses, err := PredictPasses(model, PredictPassesOptions{
|
||||
Observer: req.Observer,
|
||||
StartTime: time.Now().UTC(),
|
||||
Days: req.Days,
|
||||
MinElevationDeg: req.MinElevationDeg,
|
||||
VisibleOnly: false,
|
||||
StandardMag: entry.StandardMag,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filtered := allPasses
|
||||
if req.VisibleOnly {
|
||||
filtered = make([]SatellitePass, 0)
|
||||
for _, p := range allPasses {
|
||||
if p.Visibility == "visible" {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"satellite_id": entry.ID,
|
||||
"satellite_name": entry.Name,
|
||||
"norad_id": entry.NoradID,
|
||||
"observer": req.Observer,
|
||||
"days": req.Days,
|
||||
"visible_only": req.VisibleOnly,
|
||||
"total_count": len(allPasses),
|
||||
"count": len(filtered),
|
||||
"passes": filtered,
|
||||
})
|
||||
})
|
||||
|
||||
// Instantaneous look angles & position (for AR mode & real-time sync)
|
||||
r.POST("/position", func(c *gin.Context) {
|
||||
var req PositionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
model, entry, err := getSGP4Model(req.SatelliteID, req.CustomTLE)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
t := time.Now().UTC()
|
||||
if req.Timestamp != nil && *req.Timestamp != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, *req.Timestamp); err == nil {
|
||||
t = parsed.UTC()
|
||||
} else if parsed, err := time.Parse(time.RFC3339, *req.Timestamp); err == nil {
|
||||
t = parsed.UTC()
|
||||
}
|
||||
}
|
||||
|
||||
geo, vel, err := model.GeodeticPosition(t)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
posECEF, _, _ := model.PositionECEF(t)
|
||||
look := CalculateLookAngles(req.Observer, posECEF)
|
||||
jd := JulianDate(t)
|
||||
isSunlit := IsSatelliteIlluminated(posECEF, jd)
|
||||
sunEl := ObserverSunElevation(req.Observer, t)
|
||||
obsECEF := GeodeticToECEF(req.Observer)
|
||||
apparentMag := EstimateVisualMagnitude(entry.StandardMag, satECEF(posECEF), obsECEF, jd)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"timestamp": t.UnixNano() / 1e6, // ms
|
||||
"satellite_id": entry.ID,
|
||||
"satellite_name": entry.Name,
|
||||
"latitude": math.Round(geo.Latitude*10000) / 10000,
|
||||
"longitude": math.Round(geo.Longitude*10000) / 10000,
|
||||
"altitude_km": math.Round(geo.Altitude*10) / 10,
|
||||
"velocity_km_s": math.Round(math.Sqrt(vel.X*vel.X+vel.Y*vel.Y+vel.Z*vel.Z)*100) / 100,
|
||||
"azimuth": math.Round(look.Azimuth*10) / 10,
|
||||
"azimuth_cardinal": CardinalDirection(look.Azimuth),
|
||||
"elevation": math.Round(look.Elevation*10) / 10,
|
||||
"range_km": math.Round(look.Range*10) / 10,
|
||||
"is_sunlit": isSunlit,
|
||||
"sun_elevation": math.Round(sunEl*10) / 10,
|
||||
"apparent_mag": apparentMag,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func satECEF(v Vector3) Vector3 {
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user