435 lines
13 KiB
Go
435 lines
13 KiB
Go
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
|
|
}
|