Files
own-tools/pkg/orbitviewer/api.go
T
2026-09-23 00:06:01 -07:00

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
}