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:
+7
-4
@@ -17,8 +17,11 @@ vendor/
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
# OS files
|
# Certificates & Keys
|
||||||
.DS_Store
|
*.crt
|
||||||
|
*.key
|
||||||
|
server.crt
|
||||||
|
server.key
|
||||||
|
|
||||||
# Data (if you want to exclude original design data, but here we include pkg/jitie/data)
|
# OS
|
||||||
# data/
|
.DS_Store
|
||||||
|
|||||||
+6
-1
@@ -15,6 +15,7 @@ import (
|
|||||||
"toolbox/pkg/base"
|
"toolbox/pkg/base"
|
||||||
_ "toolbox/pkg/football"
|
_ "toolbox/pkg/football"
|
||||||
_ "toolbox/pkg/learnnumber"
|
_ "toolbox/pkg/learnnumber"
|
||||||
|
_ "toolbox/pkg/orbitviewer"
|
||||||
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -104,6 +105,10 @@ func main() {
|
|||||||
title = "2026世界杯小组出线模拟器 - 蒙特卡洛预测"
|
title = "2026世界杯小组出线模拟器 - 蒙特卡洛预测"
|
||||||
desc = "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。"
|
desc = "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。"
|
||||||
keywords = "2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测"
|
keywords = "2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测"
|
||||||
|
case "/orbit-viewer":
|
||||||
|
title = "卫星轨道观测与AR实景寻星 - 实时轨迹与过境预测"
|
||||||
|
desc = "实时模拟空间站与卫星投影地面轨迹,精准预测未来10天最佳肉眼可见过境窗口,并支持手机AR实景摄像头与陀螺仪定向寻星。"
|
||||||
|
keywords = "卫星过境预测, 空间站追踪, ISS轨迹, AR寻星, 卫星观测, 轨道投影, 空间望远镜"
|
||||||
}
|
}
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
@@ -156,7 +161,7 @@ func main() {
|
|||||||
finalPort = "8080"
|
finalPort = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Toolbox server starting on :%s", finalPort)
|
log.Printf("Toolbox HTTP server starting on :%s -> http://localhost:%s", finalPort, finalPort)
|
||||||
if err := r.Run(":" + finalPort); err != nil {
|
if err := r.Run(":" + finalPort); err != nil {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"toolbox/pkg/base"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -16,6 +22,63 @@ const (
|
|||||||
baseURL = "http://localhost:" + testPort
|
baseURL = "http://localhost:" + testPort
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestOrbitViewerTemplatesAndRoutes(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
|
||||||
|
for id, tool := range base.Registry {
|
||||||
|
if err := tool.Init(); err != nil {
|
||||||
|
t.Fatalf("Failed to initialize tool %s: %v", id, err)
|
||||||
|
}
|
||||||
|
tool.RegisterRoutes(r.Group("/api/" + id))
|
||||||
|
}
|
||||||
|
|
||||||
|
subFS, err := fs.Sub(webFS, "web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to fs.Sub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serveIndex := func(c *gin.Context) {
|
||||||
|
data := gin.H{
|
||||||
|
"Title": "测试",
|
||||||
|
"Description": "描述",
|
||||||
|
"Keywords": "关键词",
|
||||||
|
"CanonicalURL": "https://toolbox.pengzhan.dev/orbit-viewer",
|
||||||
|
"GA_ID": "",
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "Template error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
r.GET("/orbit-viewer", serveIndex)
|
||||||
|
|
||||||
|
// Test GET /orbit-viewer
|
||||||
|
req, _ := http.NewRequest("GET", "/orbit-viewer", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200 for /orbit-viewer, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := w.Body.String()
|
||||||
|
if !bytes.Contains([]byte(bodyStr), []byte("panel-orbit-viewer")) {
|
||||||
|
t.Errorf("Expected body to contain panel-orbit-viewer")
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(bodyStr), []byte("阶段一:卫星轨道与全球投影轨迹")) {
|
||||||
|
t.Errorf("Expected body to contain orbit viewer content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolboxE2E(t *testing.T) {
|
func TestToolboxE2E(t *testing.T) {
|
||||||
// 1. Build the binary
|
// 1. Build the binary
|
||||||
fmt.Println("--- Step 1: Building binary... ---")
|
fmt.Println("--- Step 1: Building binary... ---")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
{{template "zitie" .}}
|
{{template "zitie" .}}
|
||||||
{{template "learn_number" .}}
|
{{template "learn_number" .}}
|
||||||
{{template "football" .}}
|
{{template "football" .}}
|
||||||
|
{{template "orbit_viewer" .}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 首页 Tile 渲染
|
// 首页 Tile 渲染
|
||||||
|
|||||||
@@ -57,19 +57,21 @@
|
|||||||
.nav-item:hover { background: rgba(0,0,0,0.04); color: #000; }
|
.nav-item:hover { background: rgba(0,0,0,0.04); color: #000; }
|
||||||
.nav-item.active { background: var(--apple-blue); color: #ffffff; }
|
.nav-item.active { background: var(--apple-blue); color: #ffffff; }
|
||||||
|
|
||||||
.main-container { flex: 1; display: flex; flex-direction: column; overflow-y: auto; width: 100%; }
|
.main-container { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; width: 100%; max-width: 100vw; }
|
||||||
.main-content { padding: 40px 60px; width: 100%; }
|
.main-content { padding: 40px 60px; width: 100%; max-width: 100%; box-sizing: border-box; }
|
||||||
|
|
||||||
/* Unified Page Header */
|
/* Unified Page Header */
|
||||||
.page-header { margin-bottom: 32px; }
|
.page-header { margin-bottom: 32px; }
|
||||||
.page-header h1 { font-size: 34px; font-weight: 700; margin: 0 0 8px 0; }
|
.page-header h1 { font-size: 34px; font-weight: 700; margin: 0 0 8px 0; word-break: break-word; }
|
||||||
.page-header p { font-size: 18px; color: #86868b; margin: 0; }
|
.page-header p { font-size: 18px; color: #86868b; margin: 0; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { position: fixed; left: -260px; height: 100%; }
|
.sidebar { position: fixed; left: -260px; height: 100%; }
|
||||||
#menu-toggle:checked ~ .sidebar { transform: translateX(260px); }
|
#menu-toggle:checked ~ .sidebar { transform: translateX(260px); }
|
||||||
.menu-btn { display: block; }
|
.menu-btn { display: block; }
|
||||||
.main-content { padding: 80px 20px 20px; }
|
.main-content { padding: 70px 14px 20px; width: 100vw; max-width: 100vw; box-sizing: border-box; overflow-x: hidden; }
|
||||||
|
.page-header h1 { font-size: 24px; }
|
||||||
|
.page-header p { font-size: 14px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel { display: none; }
|
.tool-panel { display: none; }
|
||||||
@@ -164,6 +166,11 @@
|
|||||||
title: '2026世界杯小组出线模拟器',
|
title: '2026世界杯小组出线模拟器',
|
||||||
desc: '使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。',
|
desc: '使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。',
|
||||||
keywords: '2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测'
|
keywords: '2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测'
|
||||||
|
},
|
||||||
|
'orbit-viewer': {
|
||||||
|
title: '卫星轨道观测与AR实景寻星',
|
||||||
|
desc: '实时模拟空间站与卫星投影地面轨迹,精准预测未来10天最佳肉眼可见过境窗口,并支持手机AR实景摄像头与陀螺仪定向寻星。',
|
||||||
|
keywords: '卫星过境预测, 空间站追踪, ISS轨迹, AR寻星, 卫星观测, 轨道投影, 空间望远镜'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,6 +206,9 @@
|
|||||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
const activeNav = document.getElementById(`nav-${path}`) || document.getElementById('nav-welcome');
|
const activeNav = document.getElementById(`nav-${path}`) || document.getElementById('nav-welcome');
|
||||||
if (activeNav) activeNav.classList.add('active');
|
if (activeNav) activeNav.classList.add('active');
|
||||||
|
|
||||||
|
// 触发自定义面板切换事件,通知工具初始化或重设尺寸
|
||||||
|
window.dispatchEvent(new CustomEvent('panel-changed', { detail: { path: path } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onpopstate = renderCurrentPath;
|
window.onpopstate = renderCurrentPath;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -119,14 +119,14 @@ func drawProblemV4(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64
|
|||||||
randX := xBase + r + 5 + rand.Float64()*(boxW-2*r-10)
|
randX := xBase + r + 5 + rand.Float64()*(boxW-2*r-10)
|
||||||
randY := yBase + r + 5 + rand.Float64()*(boxH-2*r-10)
|
randY := yBase + r + 5 + rand.Float64()*(boxH-2*r-10)
|
||||||
collision := false
|
collision := false
|
||||||
for _, p := range placed {
|
for _, p := range placed {
|
||||||
dx := randX - p.X
|
dx := randX - p.X
|
||||||
dy := randY - p.Y
|
dy := randY - p.Y
|
||||||
if math.Sqrt(dx*dx+dy*dy) < (r + p.Radius + 10.0) {
|
if math.Sqrt(dx*dx+dy*dy) < (r + p.Radius + 10.0) {
|
||||||
collision = true
|
collision = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !collision {
|
if !collision {
|
||||||
drawSimpleIcon(pdf, selectedIcons[iconTypeIdx], randX, randY, r*2)
|
drawSimpleIcon(pdf, selectedIcons[iconTypeIdx], randX, randY, r*2)
|
||||||
@@ -293,12 +293,12 @@ func parseMultiPath(icon data.Icon, scale, ox, oy float64) []PathResult {
|
|||||||
x += lx
|
x += lx
|
||||||
y += ly
|
y += ly
|
||||||
}
|
}
|
||||||
for t := 0.2; t <= 1.0; t += 0.2 {
|
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||||
invT := 1 - t
|
invT := 1 - t
|
||||||
tx := invT*invT*invT*lx + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
|
tx := invT*invT*invT*lx + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
|
||||||
ty := invT*invT*invT*ly + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
|
ty := invT*invT*invT*ly + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
|
||||||
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
||||||
}
|
}
|
||||||
|
|
||||||
lx, ly = x, y
|
lx, ly = x, y
|
||||||
i += 6
|
i += 6
|
||||||
@@ -317,12 +317,12 @@ func parseMultiPath(icon data.Icon, scale, ox, oy float64) []PathResult {
|
|||||||
x += lx
|
x += lx
|
||||||
y += ly
|
y += ly
|
||||||
}
|
}
|
||||||
for t := 0.25; t <= 1.0; t += 0.25 {
|
for t := 0.25; t <= 1.0; t += 0.25 {
|
||||||
invT := 1 - t
|
invT := 1 - t
|
||||||
tx := invT*invT*lx + 2*invT*t*x1 + t*t*x
|
tx := invT*invT*lx + 2*invT*t*x1 + t*t*x
|
||||||
ty := invT*invT*ly + 2*invT*t*y1 + t*t*y
|
ty := invT*invT*ly + 2*invT*t*y1 + t*t*y
|
||||||
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
|
||||||
}
|
}
|
||||||
|
|
||||||
lx, ly = x, y
|
lx, ly = x, y
|
||||||
i += 4
|
i += 4
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestRouter() *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
api := r.Group("/api/orbit-viewer")
|
||||||
|
RegisterRoutes(api)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPISatellites(t *testing.T) {
|
||||||
|
r := setupTestRouter()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/api/orbit-viewer/satellites", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Satellites []*SatelliteCatalogEntry `json:"satellites"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Satellites) == 0 {
|
||||||
|
t.Errorf("Expected satellites in catalog, got 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasISS := false
|
||||||
|
for _, sat := range resp.Satellites {
|
||||||
|
if sat.ID == "iss" {
|
||||||
|
hasISS = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasISS {
|
||||||
|
t.Errorf("Expected ISS in satellite catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITrack(t *testing.T) {
|
||||||
|
r := setupTestRouter()
|
||||||
|
|
||||||
|
payload := TrackRequest{
|
||||||
|
SatelliteID: "iss",
|
||||||
|
Observer: &Geodetic{
|
||||||
|
Latitude: 39.9042,
|
||||||
|
Longitude: 116.4074,
|
||||||
|
Altitude: 0.05,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/api/orbit-viewer/track", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp TrackResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.SatelliteID != "iss" {
|
||||||
|
t.Errorf("Expected satellite_id iss, got %s", resp.SatelliteID)
|
||||||
|
}
|
||||||
|
if resp.AltitudeKm < 300 || resp.AltitudeKm > 500 {
|
||||||
|
t.Errorf("Unrealistic ISS altitude: %f km", resp.AltitudeKm)
|
||||||
|
}
|
||||||
|
if len(resp.PastTrack) == 0 || len(resp.FutureTrack) == 0 {
|
||||||
|
t.Errorf("Expected track points in response")
|
||||||
|
}
|
||||||
|
if resp.ObserverLook == nil {
|
||||||
|
t.Errorf("Expected ObserverLook populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIPasses(t *testing.T) {
|
||||||
|
r := setupTestRouter()
|
||||||
|
|
||||||
|
payload := PassesRequest{
|
||||||
|
SatelliteID: "iss",
|
||||||
|
Observer: Geodetic{
|
||||||
|
Latitude: 37.7749,
|
||||||
|
Longitude: -122.4194,
|
||||||
|
Altitude: 0.03,
|
||||||
|
},
|
||||||
|
Days: 10,
|
||||||
|
MinElevationDeg: 10.0,
|
||||||
|
VisibleOnly: false,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/api/orbit-viewer/passes", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Passes []SatellitePass `json:"passes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Count == 0 {
|
||||||
|
t.Errorf("Expected passes for ISS over 10 days, got 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIPosition(t *testing.T) {
|
||||||
|
r := setupTestRouter()
|
||||||
|
|
||||||
|
payload := PositionRequest{
|
||||||
|
SatelliteID: "iss",
|
||||||
|
Observer: Geodetic{
|
||||||
|
Latitude: 51.5074,
|
||||||
|
Longitude: -0.1278,
|
||||||
|
Altitude: 0.02,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/api/orbit-viewer/position", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := resp["azimuth"]; !ok {
|
||||||
|
t.Errorf("Missing azimuth in position response")
|
||||||
|
}
|
||||||
|
if _, ok := resp["elevation"]; !ok {
|
||||||
|
t.Errorf("Missing elevation in position response")
|
||||||
|
}
|
||||||
|
if _, ok := resp["range_km"]; !ok {
|
||||||
|
t.Errorf("Missing range_km in position response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPICustomTLE(t *testing.T) {
|
||||||
|
r := setupTestRouter()
|
||||||
|
|
||||||
|
payload := TLEInput{
|
||||||
|
Name: "TEST-CSS",
|
||||||
|
Line1: "1 48274U 21035A 24080.45678901 .00021456 00000-0 31456-3 0 9991",
|
||||||
|
Line2: "2 48274 41.4723 112.3456 0005123 85.2341 274.9812 15.61234567167890",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("POST", "/api/orbit-viewer/custom", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Satellite *SatelliteCatalogEntry `json:"satellite"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Failed to parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Satellite.NoradID != 48274 {
|
||||||
|
t.Errorf("Expected NORAD ID 48274, got %d", resp.Satellite.NoradID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SatelliteCatalogEntry holds metadata and orbital data for a satellite.
|
||||||
|
type SatelliteCatalogEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
NoradID int `json:"norad_id"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
StandardMag float64 `json:"standard_mag"`
|
||||||
|
TLE TLE `json:"tle"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
satellites map[string]*SatelliteCatalogEntry
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
var DefaultCatalog = NewCatalogManager()
|
||||||
|
|
||||||
|
func NewCatalogManager() *CatalogManager {
|
||||||
|
cm := &CatalogManager{
|
||||||
|
satellites: make(map[string]*SatelliteCatalogEntry),
|
||||||
|
httpClient: &http.Client{Timeout: 8 * time.Second},
|
||||||
|
}
|
||||||
|
cm.initPresets()
|
||||||
|
// Fetch fresh live TLEs from CelesTrak in background
|
||||||
|
go cm.refreshAllBackground()
|
||||||
|
return cm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *CatalogManager) refreshAllBackground() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
cm.mu.RLock()
|
||||||
|
ids := make([]string, 0, len(cm.satellites))
|
||||||
|
for id := range cm.satellites {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
_ = cm.RefreshFromCelesTrak(id)
|
||||||
|
time.Sleep(200 * time.Millisecond) // avoid hammering celestrak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *CatalogManager) initPresets() {
|
||||||
|
presets := []struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
noradID int
|
||||||
|
description string
|
||||||
|
standardMag float64
|
||||||
|
line1 string
|
||||||
|
line2 string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
id: "iss",
|
||||||
|
name: "ISS (International Space Station)",
|
||||||
|
noradID: 25544,
|
||||||
|
description: "The largest modular space station currently in low Earth orbit, frequently visible to the naked eye.",
|
||||||
|
standardMag: -1.8,
|
||||||
|
line1: "1 25544U 98067A 26265.50000000 .00014524 00000-0 26388-3 0 9993",
|
||||||
|
line2: "2 25544 51.6416 195.4568 0004928 120.1234 239.9876 15.49876543445566",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tiangong",
|
||||||
|
name: "Tiangong (CSS)",
|
||||||
|
noradID: 48274,
|
||||||
|
description: "Chinese space station in low Earth orbit between 340 and 450 km altitude.",
|
||||||
|
standardMag: -1.0,
|
||||||
|
line1: "1 48274U 21035A 26265.45678901 .00021456 00000-0 31456-3 0 9991",
|
||||||
|
line2: "2 48274 41.4723 112.3456 0005123 85.2341 274.9812 15.61234567167890",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hubble",
|
||||||
|
name: "Hubble Space Telescope (HST)",
|
||||||
|
noradID: 20580,
|
||||||
|
description: "Famous NASA/ESA optical space observatory operating in orbit since 1990.",
|
||||||
|
standardMag: 1.5,
|
||||||
|
line1: "1 20580U 90037B 26265.34567890 .00001234 00000-0 54321-4 0 9992",
|
||||||
|
line2: "2 20580 28.4691 245.1234 0002845 280.1234 79.8765 15.08765432854321",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "noaa19",
|
||||||
|
name: "NOAA 19",
|
||||||
|
noradID: 33591,
|
||||||
|
description: "Polar-orbiting operational weather satellite operated by NOAA.",
|
||||||
|
standardMag: 3.5,
|
||||||
|
line1: "1 33591U 09005A 26265.23456789 .00000123 00000-0 65432-4 0 9995",
|
||||||
|
line2: "2 33591 98.7123 145.6789 0014234 65.4321 294.7890 14.12345678789012",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terra",
|
||||||
|
name: "Terra (EOS AM-1)",
|
||||||
|
noradID: 25994,
|
||||||
|
description: "NASA flagship Earth-observing satellite in a sun-synchronous orbit.",
|
||||||
|
standardMag: 2.0,
|
||||||
|
line1: "1 25994U 99068A 26265.12345678 .00000234 00000-0 76543-4 0 9998",
|
||||||
|
line2: "2 25994 98.2123 210.3456 0001234 110.1234 250.0123 14.58765432298765",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "envisat",
|
||||||
|
name: "Envisat",
|
||||||
|
noradID: 27386,
|
||||||
|
description: "Large inactive European Earth observation satellite, bright and easily tracked.",
|
||||||
|
standardMag: 2.0,
|
||||||
|
line1: "1 27386U 02009A 26265.65432109 .00000345 00000-0 87654-4 0 9997",
|
||||||
|
line2: "2 27386 98.5432 78.9012 0001567 95.4321 264.7654 14.37654321154321",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starlink",
|
||||||
|
name: "Starlink (Sample)",
|
||||||
|
noradID: 44713,
|
||||||
|
description: "SpaceX Starlink low Earth orbit communications constellation satellite.",
|
||||||
|
standardMag: 4.5,
|
||||||
|
line1: "1 44713U 19074A 26265.51234567 .00001567 00000-0 12345-3 0 9990",
|
||||||
|
line2: "2 44713 53.0543 180.1234 0001456 80.1234 280.0123 15.06543210234567",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range presets {
|
||||||
|
tle, err := ParseTLE(p.name, p.line1, p.line2)
|
||||||
|
if err == nil {
|
||||||
|
cm.satellites[p.id] = &SatelliteCatalogEntry{
|
||||||
|
ID: p.id,
|
||||||
|
Name: p.name,
|
||||||
|
NoradID: p.noradID,
|
||||||
|
Description: p.description,
|
||||||
|
StandardMag: p.standardMag,
|
||||||
|
TLE: tle,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all catalog entries sorted.
|
||||||
|
func (cm *CatalogManager) GetAll() []*SatelliteCatalogEntry {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*SatelliteCatalogEntry, 0, len(cm.satellites))
|
||||||
|
for _, e := range cm.satellites {
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns an entry by ID or NORAD ID string.
|
||||||
|
func (cm *CatalogManager) Get(idOrNorad string) (*SatelliteCatalogEntry, bool) {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
if e, ok := cm.satellites[idOrNorad]; ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
for _, e := range cm.satellites {
|
||||||
|
if fmt.Sprintf("%d", e.NoradID) == idOrNorad {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshFromCelesTrak fetches updated TLE for a given catalog entry by NORAD ID.
|
||||||
|
func (cm *CatalogManager) RefreshFromCelesTrak(id string) error {
|
||||||
|
cm.mu.RLock()
|
||||||
|
entry, ok := cm.satellites[id]
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("satellite ID %s not found in catalog", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := []string{
|
||||||
|
fmt.Sprintf("https://celestrak.org/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID),
|
||||||
|
fmt.Sprintf("https://celestrak.com/NORAD/elements/gp.php?CATNR=%d&FORMAT=TLE", entry.NoradID),
|
||||||
|
}
|
||||||
|
|
||||||
|
var body []byte
|
||||||
|
var lastErr error
|
||||||
|
for _, targetURL := range urls {
|
||||||
|
req, err := http.NewRequest("GET", targetURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "text/plain, */*")
|
||||||
|
|
||||||
|
resp, err := cm.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err == nil && len(data) > 60 {
|
||||||
|
body = data
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("celestrak returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil && len(body) == 0 {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n")
|
||||||
|
var l1, l2, name string
|
||||||
|
for _, l := range lines {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if strings.HasPrefix(l, "1 ") && len(l) >= 68 {
|
||||||
|
l1 = l
|
||||||
|
} else if strings.HasPrefix(l, "2 ") && len(l) >= 68 {
|
||||||
|
l2 = l
|
||||||
|
} else if l != "" && !strings.HasPrefix(l, "1 ") && !strings.HasPrefix(l, "2 ") && name == "" {
|
||||||
|
name = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if l1 == "" || l2 == "" {
|
||||||
|
return fmt.Errorf("invalid TLE response from CelesTrak for NORAD %d", entry.NoradID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = entry.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
tle, err := ParseTLE(name, l1, l2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.mu.Lock()
|
||||||
|
entry.TLE = tle
|
||||||
|
entry.UpdatedAt = time.Now()
|
||||||
|
cm.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCustomTLE adds or updates a custom user-supplied TLE into the catalog.
|
||||||
|
func (cm *CatalogManager) AddCustomTLE(name, line1, line2 string) (*SatelliteCatalogEntry, error) {
|
||||||
|
tle, err := ParseTLE(name, line1, line2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id := fmt.Sprintf("custom-%d", tle.NoradID)
|
||||||
|
entry := &SatelliteCatalogEntry{
|
||||||
|
ID: id,
|
||||||
|
Name: tle.Name,
|
||||||
|
NoradID: tle.NoradID,
|
||||||
|
Description: "User-defined custom satellite orbital parameters",
|
||||||
|
StandardMag: 3.0,
|
||||||
|
TLE: tle,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.mu.Lock()
|
||||||
|
cm.satellites[id] = entry
|
||||||
|
cm.mu.Unlock()
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EarthRadiusKm = 6378.137 // WGS-84 equatorial radius in km
|
||||||
|
EarthFlattening = 1.0 / 298.257223563
|
||||||
|
Deg2Rad = math.Pi / 180.0
|
||||||
|
Rad2Deg = 180.0 / math.Pi
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vector3 represents a 3D vector [x, y, z] in km or km/s.
|
||||||
|
type Vector3 struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Z float64 `json:"z"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geodetic represents latitude, longitude (in degrees) and altitude (in km).
|
||||||
|
type Geodetic struct {
|
||||||
|
Latitude float64 `json:"latitude"` // Degrees (-90 to +90)
|
||||||
|
Longitude float64 `json:"longitude"` // Degrees (-180 to +180)
|
||||||
|
Altitude float64 `json:"altitude"` // Kilometers above WGS-84 ellipsoid
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookAngles represents observer-relative topocentric angles.
|
||||||
|
type LookAngles struct {
|
||||||
|
Azimuth float64 `json:"azimuth"` // Degrees (0 to 360, 0=North, 90=East)
|
||||||
|
Elevation float64 `json:"elevation"` // Degrees (-90 to +90)
|
||||||
|
Range float64 `json:"range"` // Distance in km
|
||||||
|
}
|
||||||
|
|
||||||
|
// JulianDate calculates the Julian Date for a given UTC time.
|
||||||
|
func JulianDate(t time.Time) float64 {
|
||||||
|
t = t.UTC()
|
||||||
|
year := t.Year()
|
||||||
|
month := int(t.Month())
|
||||||
|
day := t.Day()
|
||||||
|
hour := t.Hour()
|
||||||
|
min := t.Minute()
|
||||||
|
sec := t.Second()
|
||||||
|
nsec := t.Nanosecond()
|
||||||
|
|
||||||
|
dayFraction := (float64(hour) + float64(min)/60.0 + (float64(sec)+float64(nsec)/1e9)/3600.0) / 24.0
|
||||||
|
|
||||||
|
if month <= 2 {
|
||||||
|
year--
|
||||||
|
month += 12
|
||||||
|
}
|
||||||
|
|
||||||
|
a := math.Floor(float64(year) / 100.0)
|
||||||
|
b := 2.0 - a + math.Floor(a/4.0)
|
||||||
|
|
||||||
|
jd := math.Floor(365.25*float64(year+4716)) +
|
||||||
|
math.Floor(30.6001*float64(month+1)) +
|
||||||
|
float64(day) + dayFraction + b - 1524.5
|
||||||
|
|
||||||
|
return jd
|
||||||
|
}
|
||||||
|
|
||||||
|
// GMST calculates Greenwich Mean Sidereal Time in radians for a given Julian Date.
|
||||||
|
func GMST(jd float64) float64 {
|
||||||
|
d := jd - 2451545.0 // Days since J2000.0
|
||||||
|
// Standard formula in degrees:
|
||||||
|
gmstDeg := 280.46061837 + 360.98564736629*d
|
||||||
|
// Normalize to 0 .. 360
|
||||||
|
gmstDeg = math.Mod(gmstDeg, 360.0)
|
||||||
|
if gmstDeg < 0 {
|
||||||
|
gmstDeg += 360.0
|
||||||
|
}
|
||||||
|
return gmstDeg * Deg2Rad
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEMEtoECEF converts True Equator Mean Equinox (TEME) coordinates to Earth-Centered Earth-Fixed (ECEF) coordinates.
|
||||||
|
func TEMEtoECEF(pos Vector3, jd float64) Vector3 {
|
||||||
|
theta := GMST(jd)
|
||||||
|
cosTheta := math.Cos(theta)
|
||||||
|
sinTheta := math.Sin(theta)
|
||||||
|
|
||||||
|
return Vector3{
|
||||||
|
X: pos.X*cosTheta + pos.Y*sinTheta,
|
||||||
|
Y: -pos.X*sinTheta + pos.Y*cosTheta,
|
||||||
|
Z: pos.Z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECEFtoGeodetic converts ECEF coordinates (in km) to WGS-84 Geodetic (Lat, Lon, Alt).
|
||||||
|
func ECEFtoGeodetic(pos Vector3) Geodetic {
|
||||||
|
x := pos.X
|
||||||
|
y := pos.Y
|
||||||
|
z := pos.Z
|
||||||
|
|
||||||
|
a := EarthRadiusKm
|
||||||
|
f := EarthFlattening
|
||||||
|
b := a * (1.0 - f)
|
||||||
|
e2 := (a*a - b*b) / (a * a)
|
||||||
|
ep2 := (a*a - b*b) / (b * b)
|
||||||
|
|
||||||
|
p := math.Sqrt(x*x + y*y)
|
||||||
|
theta := math.Atan2(z*a, p*b)
|
||||||
|
|
||||||
|
sinTheta := math.Sin(theta)
|
||||||
|
cosTheta := math.Cos(theta)
|
||||||
|
|
||||||
|
lat := math.Atan2(z+ep2*b*sinTheta*sinTheta*sinTheta, p-e2*a*cosTheta*cosTheta*cosTheta)
|
||||||
|
lon := math.Atan2(y, x)
|
||||||
|
|
||||||
|
sinLat := math.Sin(lat)
|
||||||
|
n := a / math.Sqrt(1.0-e2*sinLat*sinLat)
|
||||||
|
alt := p/math.Cos(lat) - n
|
||||||
|
|
||||||
|
latDeg := lat * Rad2Deg
|
||||||
|
lonDeg := lon * Rad2Deg
|
||||||
|
// Normalize longitude to -180..180
|
||||||
|
for lonDeg > 180 {
|
||||||
|
lonDeg -= 360
|
||||||
|
}
|
||||||
|
for lonDeg < -180 {
|
||||||
|
lonDeg += 360
|
||||||
|
}
|
||||||
|
|
||||||
|
return Geodetic{
|
||||||
|
Latitude: latDeg,
|
||||||
|
Longitude: lonDeg,
|
||||||
|
Altitude: alt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeodeticToECEF converts WGS-84 Geodetic (lat, lon degrees, alt km) to ECEF (km).
|
||||||
|
func GeodeticToECEF(geo Geodetic) Vector3 {
|
||||||
|
latRad := geo.Latitude * Deg2Rad
|
||||||
|
lonRad := geo.Longitude * Deg2Rad
|
||||||
|
|
||||||
|
a := EarthRadiusKm
|
||||||
|
f := EarthFlattening
|
||||||
|
e2 := 2*f - f*f
|
||||||
|
|
||||||
|
sinLat := math.Sin(latRad)
|
||||||
|
cosLat := math.Cos(latRad)
|
||||||
|
sinLon := math.Sin(lonRad)
|
||||||
|
cosLon := math.Cos(lonRad)
|
||||||
|
|
||||||
|
n := a / math.Sqrt(1.0-e2*sinLat*sinLat)
|
||||||
|
|
||||||
|
return Vector3{
|
||||||
|
X: (n + geo.Altitude) * cosLat * cosLon,
|
||||||
|
Y: (n + geo.Altitude) * cosLat * sinLon,
|
||||||
|
Z: (n*(1.0-e2) + geo.Altitude) * sinLat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateLookAngles computes Topocentric Azimuth (deg), Elevation (deg), and Range (km)
|
||||||
|
// of a target ECEF position from an observer Geodetic location.
|
||||||
|
func CalculateLookAngles(obs Geodetic, targetECEF Vector3) LookAngles {
|
||||||
|
obsECEF := GeodeticToECEF(obs)
|
||||||
|
|
||||||
|
// Range vector in ECEF
|
||||||
|
rx := targetECEF.X - obsECEF.X
|
||||||
|
ry := targetECEF.Y - obsECEF.Y
|
||||||
|
rz := targetECEF.Z - obsECEF.Z
|
||||||
|
rangeDist := math.Sqrt(rx*rx + ry*ry + rz*rz)
|
||||||
|
|
||||||
|
if rangeDist < 1e-6 {
|
||||||
|
return LookAngles{Azimuth: 0, Elevation: 90, Range: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
latRad := obs.Latitude * Deg2Rad
|
||||||
|
lonRad := obs.Longitude * Deg2Rad
|
||||||
|
|
||||||
|
sinLat := math.Sin(latRad)
|
||||||
|
cosLat := math.Cos(latRad)
|
||||||
|
sinLon := math.Sin(lonRad)
|
||||||
|
cosLon := math.Cos(lonRad)
|
||||||
|
|
||||||
|
// Topocentric horizon coordinates (East, North, Up)
|
||||||
|
east := -sinLon*rx + cosLon*ry
|
||||||
|
north := -sinLat*cosLon*rx - sinLat*sinLon*ry + cosLat*rz
|
||||||
|
up := cosLat*cosLon*rx + cosLat*sinLon*ry + sinLat*rz
|
||||||
|
|
||||||
|
azRad := math.Atan2(east, north)
|
||||||
|
azDeg := azRad * Rad2Deg
|
||||||
|
if azDeg < 0 {
|
||||||
|
azDeg += 360.0
|
||||||
|
}
|
||||||
|
|
||||||
|
elRad := math.Asin(up / rangeDist)
|
||||||
|
elDeg := elRad * Rad2Deg
|
||||||
|
|
||||||
|
return LookAngles{
|
||||||
|
Azimuth: azDeg,
|
||||||
|
Elevation: elDeg,
|
||||||
|
Range: rangeDist,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FootprintRadiusKm returns the visual/radio horizon footprint radius on Earth's surface for an altitude in km.
|
||||||
|
func FootprintRadiusKm(altKm float64) float64 {
|
||||||
|
if altKm <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r := EarthRadiusKm
|
||||||
|
// Central angle theta: cos(theta) = r / (r + h)
|
||||||
|
cosTheta := r / (r + altKm)
|
||||||
|
if cosTheta > 1.0 {
|
||||||
|
cosTheta = 1.0
|
||||||
|
}
|
||||||
|
theta := math.Acos(cosTheta)
|
||||||
|
return r * theta // Arc distance on Earth surface
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardinalDirection converts azimuth degrees to an 8 or 16-point cardinal compass string.
|
||||||
|
func CardinalDirection(az float64) string {
|
||||||
|
az = math.Mod(az, 360.0)
|
||||||
|
if az < 0 {
|
||||||
|
az += 360.0
|
||||||
|
}
|
||||||
|
dirs := []string{
|
||||||
|
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||||
|
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N",
|
||||||
|
}
|
||||||
|
idx := int(math.Round(az / 22.5))
|
||||||
|
if idx >= len(dirs) {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
return dirs[idx]
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PassPoint represents a single point in time during a pass.
|
||||||
|
type PassPoint struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
TimeFormatted string `json:"time_formatted"`
|
||||||
|
Azimuth float64 `json:"azimuth"`
|
||||||
|
AzimuthCardinal string `json:"azimuth_cardinal"`
|
||||||
|
Elevation float64 `json:"elevation"`
|
||||||
|
RangeKm float64 `json:"range_km"`
|
||||||
|
SunElevation float64 `json:"sun_elevation"`
|
||||||
|
IsSunlit bool `json:"is_sunlit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatellitePass describes a predicted pass over an observer's location.
|
||||||
|
type SatellitePass struct {
|
||||||
|
StartTimeFormatted string `json:"start_time_formatted"`
|
||||||
|
Start PassPoint `json:"start"`
|
||||||
|
Max PassPoint `json:"max"`
|
||||||
|
End PassPoint `json:"end"`
|
||||||
|
DurationSec int `json:"duration_sec"`
|
||||||
|
DurationFormatted string `json:"duration_formatted"`
|
||||||
|
Visibility string `json:"visibility"` // "visible", "daylight", "eclipsed"
|
||||||
|
VisibilityLabel string `json:"visibility_label"`
|
||||||
|
EstimatedMag float64 `json:"estimated_mag"`
|
||||||
|
Trajectory []PassPoint `json:"trajectory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PredictPassesOptions configures the pass prediction query.
|
||||||
|
type PredictPassesOptions struct {
|
||||||
|
Observer Geodetic `json:"observer"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
Days int `json:"days"` // default 10
|
||||||
|
MinElevationDeg float64 `json:"min_elevation_deg"` // default 10.0
|
||||||
|
VisibleOnly bool `json:"visible_only"` // default false
|
||||||
|
StandardMag float64 `json:"standard_mag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PredictPasses scans the given time window and returns predicted satellite passes.
|
||||||
|
func PredictPasses(model *SGP4Model, opts PredictPassesOptions) ([]SatellitePass, error) {
|
||||||
|
if opts.Days <= 0 || opts.Days > 14 {
|
||||||
|
opts.Days = 10
|
||||||
|
}
|
||||||
|
if opts.MinElevationDeg < 0 {
|
||||||
|
opts.MinElevationDeg = 10.0
|
||||||
|
}
|
||||||
|
if opts.StartTime.IsZero() {
|
||||||
|
opts.StartTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime := opts.StartTime.Add(time.Duration(opts.Days) * 24 * time.Hour)
|
||||||
|
step := 45 * time.Second // Coarse search step
|
||||||
|
|
||||||
|
var passes []SatellitePass
|
||||||
|
t := opts.StartTime
|
||||||
|
|
||||||
|
inPass := false
|
||||||
|
var passPoints []PassPoint
|
||||||
|
|
||||||
|
for t.Before(endTime) {
|
||||||
|
posECEF, _, err := model.PositionECEF(t)
|
||||||
|
if err != nil {
|
||||||
|
// In case of propagation issue, advance
|
||||||
|
t = t.Add(step)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
look := CalculateLookAngles(opts.Observer, posECEF)
|
||||||
|
isAbove := look.Elevation >= opts.MinElevationDeg
|
||||||
|
|
||||||
|
if isAbove {
|
||||||
|
if !inPass {
|
||||||
|
// Just entered pass: backtrack and find exact AOS (elevation crosses minElevation)
|
||||||
|
inPass = true
|
||||||
|
passPoints = nil
|
||||||
|
aosPoint := refineAOS(model, opts.Observer, t.Add(-step), t, opts.MinElevationDeg)
|
||||||
|
passPoints = append(passPoints, aosPoint)
|
||||||
|
}
|
||||||
|
jd := JulianDate(t)
|
||||||
|
sunEl := ObserverSunElevation(opts.Observer, t)
|
||||||
|
sunlit := IsSatelliteIlluminated(posECEF, jd)
|
||||||
|
passPoints = append(passPoints, PassPoint{
|
||||||
|
Time: t,
|
||||||
|
TimeFormatted: t.Format("15:04:05"),
|
||||||
|
Azimuth: math.Round(look.Azimuth*10) / 10,
|
||||||
|
AzimuthCardinal: CardinalDirection(look.Azimuth),
|
||||||
|
Elevation: math.Round(look.Elevation*10) / 10,
|
||||||
|
RangeKm: math.Round(look.Range),
|
||||||
|
SunElevation: math.Round(sunEl*10) / 10,
|
||||||
|
IsSunlit: sunlit,
|
||||||
|
})
|
||||||
|
// Inside pass, take finer 15s steps to capture peak elevation accurately
|
||||||
|
t = t.Add(15 * time.Second)
|
||||||
|
} else {
|
||||||
|
if inPass {
|
||||||
|
// Just exited pass: refine exact LOS (elevation drops below minElevation)
|
||||||
|
losPoint := refineLOS(model, opts.Observer, t.Add(-15*time.Second), t, opts.MinElevationDeg)
|
||||||
|
passPoints = append(passPoints, losPoint)
|
||||||
|
inPass = false
|
||||||
|
|
||||||
|
// Analyze the completed pass
|
||||||
|
if pass, ok := evaluatePass(passPoints, opts); ok {
|
||||||
|
if !opts.VisibleOnly || pass.Visibility == "visible" {
|
||||||
|
passes = append(passes, pass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passPoints = nil
|
||||||
|
}
|
||||||
|
// Normal coarse stepping
|
||||||
|
t = t.Add(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluatePass(pts []PassPoint, opts PredictPassesOptions) (SatellitePass, bool) {
|
||||||
|
if len(pts) < 2 {
|
||||||
|
return SatellitePass{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
start := pts[0]
|
||||||
|
end := pts[len(pts)-1]
|
||||||
|
|
||||||
|
// Find max elevation point
|
||||||
|
maxPt := pts[0]
|
||||||
|
for _, p := range pts {
|
||||||
|
if p.Elevation > maxPt.Elevation {
|
||||||
|
maxPt = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
durationSec := int(end.Time.Sub(start.Time).Seconds())
|
||||||
|
if durationSec <= 30 {
|
||||||
|
return SatellitePass{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify visibility based on observer sun elevation and satellite illumination
|
||||||
|
visibility := "daylight"
|
||||||
|
label := "Daylight"
|
||||||
|
estimatedMag := 99.0
|
||||||
|
|
||||||
|
// Sun elevation < -6° means civil twilight / night for observer
|
||||||
|
if maxPt.SunElevation < -6.0 {
|
||||||
|
if maxPt.IsSunlit {
|
||||||
|
visibility = "visible"
|
||||||
|
label = "Visible"
|
||||||
|
|
||||||
|
// Calculate apparent magnitude at max elevation
|
||||||
|
jd := JulianDate(maxPt.Time)
|
||||||
|
satECEF, _, _ := CalculateSatECEF(opts.Observer, maxPt)
|
||||||
|
obsECEF := GeodeticToECEF(opts.Observer)
|
||||||
|
estimatedMag = EstimateVisualMagnitude(opts.StandardMag, satECEF, obsECEF, jd)
|
||||||
|
} else {
|
||||||
|
visibility = "eclipsed"
|
||||||
|
label = "In Shadow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
durMin := durationSec / 60
|
||||||
|
durSec := durationSec % 60
|
||||||
|
|
||||||
|
return SatellitePass{
|
||||||
|
StartTimeFormatted: start.Time.Local().Format("Jan 02, 15:04:05"),
|
||||||
|
Start: start,
|
||||||
|
Max: maxPt,
|
||||||
|
End: end,
|
||||||
|
DurationSec: durationSec,
|
||||||
|
DurationFormatted: fmt.Sprintf("%dm %ds", durMin, durSec),
|
||||||
|
Visibility: visibility,
|
||||||
|
VisibilityLabel: label,
|
||||||
|
EstimatedMag: estimatedMag,
|
||||||
|
Trajectory: pts,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateSatECEF reconstitutes sat position from look angles or propagation
|
||||||
|
func CalculateSatECEF(obs Geodetic, p PassPoint) (Vector3, Vector3, error) {
|
||||||
|
obsECEF := GeodeticToECEF(obs)
|
||||||
|
latRad := obs.Latitude * Deg2Rad
|
||||||
|
lonRad := obs.Longitude * Deg2Rad
|
||||||
|
azRad := p.Azimuth * Deg2Rad
|
||||||
|
elRad := p.Elevation * Deg2Rad
|
||||||
|
|
||||||
|
sinLat := math.Sin(latRad)
|
||||||
|
cosLat := math.Cos(latRad)
|
||||||
|
sinLon := math.Sin(lonRad)
|
||||||
|
cosLon := math.Cos(lonRad)
|
||||||
|
|
||||||
|
// Topocentric ENU
|
||||||
|
east := p.RangeKm * math.Cos(elRad) * math.Sin(azRad)
|
||||||
|
north := p.RangeKm * math.Cos(elRad) * math.Cos(azRad)
|
||||||
|
up := p.RangeKm * math.Sin(elRad)
|
||||||
|
|
||||||
|
rx := -sinLon*east - sinLat*cosLon*north + cosLat*cosLon*up
|
||||||
|
ry := cosLon*east - sinLat*sinLon*north + cosLat*sinLon*up
|
||||||
|
rz := cosLat*north + sinLat*up
|
||||||
|
|
||||||
|
return Vector3{
|
||||||
|
X: obsECEF.X + rx,
|
||||||
|
Y: obsECEF.Y + ry,
|
||||||
|
Z: obsECEF.Z + rz,
|
||||||
|
}, Vector3{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refineAOS uses binary search to pinpoint when satellite rises above minEl
|
||||||
|
func refineAOS(model *SGP4Model, obs Geodetic, tLow, tHigh time.Time, minEl float64) PassPoint {
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
tMid := tLow.Add(tHigh.Sub(tLow) / 2)
|
||||||
|
posECEF, _, err := model.PositionECEF(tMid)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
look := CalculateLookAngles(obs, posECEF)
|
||||||
|
if look.Elevation >= minEl {
|
||||||
|
tHigh = tMid
|
||||||
|
} else {
|
||||||
|
tLow = tMid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posECEF, _, _ := model.PositionECEF(tHigh)
|
||||||
|
look := CalculateLookAngles(obs, posECEF)
|
||||||
|
jd := JulianDate(tHigh)
|
||||||
|
sunEl := ObserverSunElevation(obs, tHigh)
|
||||||
|
sunlit := IsSatelliteIlluminated(posECEF, jd)
|
||||||
|
return PassPoint{
|
||||||
|
Time: tHigh,
|
||||||
|
TimeFormatted: tHigh.Format("15:04:05"),
|
||||||
|
Azimuth: math.Round(look.Azimuth*10) / 10,
|
||||||
|
AzimuthCardinal: CardinalDirection(look.Azimuth),
|
||||||
|
Elevation: math.Round(look.Elevation*10) / 10,
|
||||||
|
RangeKm: math.Round(look.Range),
|
||||||
|
SunElevation: math.Round(sunEl*10) / 10,
|
||||||
|
IsSunlit: sunlit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refineLOS uses binary search to pinpoint when satellite drops below minEl
|
||||||
|
func refineLOS(model *SGP4Model, obs Geodetic, tLow, tHigh time.Time, minEl float64) PassPoint {
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
tMid := tLow.Add(tHigh.Sub(tLow) / 2)
|
||||||
|
posECEF, _, err := model.PositionECEF(tMid)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
look := CalculateLookAngles(obs, posECEF)
|
||||||
|
if look.Elevation >= minEl {
|
||||||
|
tLow = tMid
|
||||||
|
} else {
|
||||||
|
tHigh = tMid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
posECEF, _, _ := model.PositionECEF(tLow)
|
||||||
|
look := CalculateLookAngles(obs, posECEF)
|
||||||
|
jd := JulianDate(tLow)
|
||||||
|
sunEl := ObserverSunElevation(obs, tLow)
|
||||||
|
sunlit := IsSatelliteIlluminated(posECEF, jd)
|
||||||
|
return PassPoint{
|
||||||
|
Time: tLow,
|
||||||
|
TimeFormatted: tLow.Format("15:04:05"),
|
||||||
|
Azimuth: math.Round(look.Azimuth*10) / 10,
|
||||||
|
AzimuthCardinal: CardinalDirection(look.Azimuth),
|
||||||
|
Elevation: math.Round(look.Elevation*10) / 10,
|
||||||
|
RangeKm: math.Round(look.Range),
|
||||||
|
SunElevation: math.Round(sunEl*10) / 10,
|
||||||
|
IsSunlit: sunlit,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WGS-72 constants used in standard SGP4/SDP4
|
||||||
|
const (
|
||||||
|
sgp4Ck2 = 5.413080e-4 // 0.5 * J2
|
||||||
|
sgp4Ck4 = 0.62098875e-6 // -0.375 * J4
|
||||||
|
sgp4E6a = 1.0e-6
|
||||||
|
sgp4Qo = 120.0 // Parameter for atmospheric model
|
||||||
|
sgp4So = 78.0 // Parameter for atmospheric model
|
||||||
|
sgp4Xke = 0.0743669161 // sqrt(GM) in (Earth radii)^1.5 / min
|
||||||
|
sgp4Xkmper = 6378.135 // Earth equatorial radius in km (WGS-72)
|
||||||
|
sgp4Xj2 = 1.082616e-3 // J2 harmonic
|
||||||
|
sgp4Xj3 = -0.253881e-5 // J3 harmonic
|
||||||
|
sgp4Xj4 = -1.65597e-6 // J4 harmonic
|
||||||
|
sgp4TwoPi = 2.0 * math.Pi
|
||||||
|
)
|
||||||
|
|
||||||
|
// TLE represents a decoded Two-Line Element set.
|
||||||
|
type TLE struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Line1 string `json:"line1"`
|
||||||
|
Line2 string `json:"line2"`
|
||||||
|
NoradID int `json:"norad_id"`
|
||||||
|
Classification string `json:"classification"`
|
||||||
|
IntlDesignator string `json:"intl_designator"`
|
||||||
|
EpochYear int `json:"epoch_year"`
|
||||||
|
EpochDay float64 `json:"epoch_day"`
|
||||||
|
EpochTime time.Time `json:"epoch_time"`
|
||||||
|
BStar float64 `json:"bstar"`
|
||||||
|
InclinationDeg float64 `json:"inclination_deg"`
|
||||||
|
RaanDeg float64 `json:"raan_deg"`
|
||||||
|
Eccentricity float64 `json:"eccentricity"`
|
||||||
|
ArgPerigeeDeg float64 `json:"arg_perigee_deg"`
|
||||||
|
MeanAnomalyDeg float64 `json:"mean_anomaly_deg"`
|
||||||
|
MeanMotionRevDay float64 `json:"mean_motion_rev_day"` // revs per day
|
||||||
|
RevNumber int `json:"rev_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SGP4Model contains initialized constants and precomputed values for orbit propagation.
|
||||||
|
type SGP4Model struct {
|
||||||
|
TLE TLE
|
||||||
|
|
||||||
|
// Orbital elements at epoch
|
||||||
|
a0, e0, i0, om0, w0, m0 float64
|
||||||
|
n0 float64 // rad / min
|
||||||
|
bstar float64
|
||||||
|
|
||||||
|
// Precomputed constants
|
||||||
|
cosi0, sini0 float64
|
||||||
|
theta2, theta4 float64
|
||||||
|
beta0, beta02, beta04 float64
|
||||||
|
ao, no0 float64
|
||||||
|
isImpSimple bool
|
||||||
|
|
||||||
|
// Secular rate terms
|
||||||
|
xmdot, omgdot, xnodot float64
|
||||||
|
c1, c2, c3, c4, c5 float64
|
||||||
|
d2, d3, d4 float64
|
||||||
|
t2cof, t3cof, t4cof, t5cof float64
|
||||||
|
eta float64
|
||||||
|
omgcof, xmcof float64
|
||||||
|
delmo, sinmao float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTLE parses two (or three) lines of standard NORAD TLE text into a TLE struct.
|
||||||
|
func ParseTLE(name, line1, line2 string) (TLE, error) {
|
||||||
|
line1 = strings.TrimSpace(line1)
|
||||||
|
line2 = strings.TrimSpace(line2)
|
||||||
|
|
||||||
|
if len(line1) < 68 || len(line2) < 68 {
|
||||||
|
return TLE{}, errors.New("invalid TLE: line length must be at least 68 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
if line1[0] != '1' || line2[0] != '2' {
|
||||||
|
return TLE{}, errors.New("invalid TLE: line 1 must start with '1' and line 2 with '2'")
|
||||||
|
}
|
||||||
|
|
||||||
|
noradID1, err := strconv.Atoi(strings.TrimSpace(line1[2:7]))
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid NORAD ID in line 1: %w", err)
|
||||||
|
}
|
||||||
|
noradID2, err := strconv.Atoi(strings.TrimSpace(line2[2:7]))
|
||||||
|
if err != nil || noradID1 != noradID2 {
|
||||||
|
return TLE{}, fmt.Errorf("NORAD ID mismatch between line 1 (%d) and line 2 (%d)", noradID1, noradID2)
|
||||||
|
}
|
||||||
|
|
||||||
|
class := string(line1[7])
|
||||||
|
intlDesig := strings.TrimSpace(line1[9:17])
|
||||||
|
|
||||||
|
epochYr2, err := strconv.Atoi(strings.TrimSpace(line1[18:20]))
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid epoch year: %w", err)
|
||||||
|
}
|
||||||
|
var fullYear int
|
||||||
|
if epochYr2 < 57 {
|
||||||
|
fullYear = 2000 + epochYr2
|
||||||
|
} else {
|
||||||
|
fullYear = 1900 + epochYr2
|
||||||
|
}
|
||||||
|
|
||||||
|
epochDay, err := strconv.ParseFloat(strings.TrimSpace(line1[20:32]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid epoch day: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate epoch timestamp
|
||||||
|
startOfYear := time.Date(fullYear, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
epochDuration := time.Duration(epochDay * float64(24*time.Hour))
|
||||||
|
epochTime := startOfYear.Add(epochDuration - 24*time.Hour)
|
||||||
|
|
||||||
|
// BSTAR drag term: e.g. " 12345-4" -> 0.12345e-4
|
||||||
|
bstarStr := strings.TrimSpace(line1[53:61])
|
||||||
|
bstar, err := parseExponential(bstarStr)
|
||||||
|
if err != nil {
|
||||||
|
bstar = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line 2 elements
|
||||||
|
inc, err := strconv.ParseFloat(strings.TrimSpace(line2[8:16]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid inclination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raan, err := strconv.ParseFloat(strings.TrimSpace(line2[17:25]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid RAAN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eccStr := "0." + strings.TrimSpace(line2[26:33])
|
||||||
|
ecc, err := strconv.ParseFloat(eccStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid eccentricity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
argPerigee, err := strconv.ParseFloat(strings.TrimSpace(line2[34:42]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid arg of perigee: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meanAnomaly, err := strconv.ParseFloat(strings.TrimSpace(line2[43:51]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid mean anomaly: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meanMotion, err := strconv.ParseFloat(strings.TrimSpace(line2[52:63]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return TLE{}, fmt.Errorf("invalid mean motion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
revNum, _ := strconv.Atoi(strings.TrimSpace(line2[63:68]))
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("SAT-%d", noradID1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TLE{
|
||||||
|
Name: name,
|
||||||
|
Line1: line1,
|
||||||
|
Line2: line2,
|
||||||
|
NoradID: noradID1,
|
||||||
|
Classification: class,
|
||||||
|
IntlDesignator: intlDesig,
|
||||||
|
EpochYear: fullYear,
|
||||||
|
EpochDay: epochDay,
|
||||||
|
EpochTime: epochTime,
|
||||||
|
BStar: bstar,
|
||||||
|
InclinationDeg: inc,
|
||||||
|
RaanDeg: raan,
|
||||||
|
Eccentricity: ecc,
|
||||||
|
ArgPerigeeDeg: argPerigee,
|
||||||
|
MeanAnomalyDeg: meanAnomaly,
|
||||||
|
MeanMotionRevDay: meanMotion,
|
||||||
|
RevNumber: revNum,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExponential(s string) (float64, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || s == "00000-0" || s == "00000+0" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
sign := 1.0
|
||||||
|
switch s[0] {
|
||||||
|
case '-':
|
||||||
|
sign = -1.0
|
||||||
|
s = s[1:]
|
||||||
|
case '+':
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
// Format is typically 5 digits followed by sign and exponent: e.g. "12345-4"
|
||||||
|
n := len(s)
|
||||||
|
if n >= 2 && (s[n-2] == '-' || s[n-2] == '+') {
|
||||||
|
mantissaStr := "0." + s[:n-2]
|
||||||
|
expStr := s[n-2:]
|
||||||
|
m, err := strconv.ParseFloat(mantissaStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
e, err := strconv.Atoi(expStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return sign * m * math.Pow10(e), nil
|
||||||
|
}
|
||||||
|
return strconv.ParseFloat(s, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSGP4Model initializes the SGP4 propagation model from a TLE.
|
||||||
|
func NewSGP4Model(tle TLE) (*SGP4Model, error) {
|
||||||
|
m := &SGP4Model{
|
||||||
|
TLE: tle,
|
||||||
|
i0: tle.InclinationDeg * Deg2Rad,
|
||||||
|
om0: tle.RaanDeg * Deg2Rad,
|
||||||
|
e0: tle.Eccentricity,
|
||||||
|
w0: tle.ArgPerigeeDeg * Deg2Rad,
|
||||||
|
m0: tle.MeanAnomalyDeg * Deg2Rad,
|
||||||
|
n0: tle.MeanMotionRevDay * sgp4TwoPi / 1440.0, // rad/min
|
||||||
|
bstar: tle.BStar,
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.e0 < 0 || m.e0 >= 0.999 {
|
||||||
|
return nil, fmt.Errorf("eccentricity %.4f out of range for SGP4", m.e0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
m.cosi0 = math.Cos(m.i0)
|
||||||
|
m.sini0 = math.Sin(m.i0)
|
||||||
|
m.theta2 = m.cosi0 * m.cosi0
|
||||||
|
m.theta4 = m.theta2 * m.theta2
|
||||||
|
m.beta02 = 1.0 - m.e0*m.e0
|
||||||
|
m.beta0 = math.Sqrt(m.beta02)
|
||||||
|
m.beta04 = m.beta02 * m.beta02
|
||||||
|
|
||||||
|
// Recover original mean motion (no0) and semi-major axis (ao)
|
||||||
|
a1 := math.Pow(sgp4Xke/m.n0, 2.0/3.0)
|
||||||
|
del1 := 1.5 * sgp4Ck2 * (3.0*m.theta2 - 1.0) / (m.beta0 * m.beta02 * a1 * a1)
|
||||||
|
ao := a1 * (1.0 - del1*(1.0/3.0+del1*(1.0+del1*134.0/81.0)))
|
||||||
|
delo := 1.5 * sgp4Ck2 * (3.0*m.theta2 - 1.0) / (m.beta0 * m.beta02 * ao * ao)
|
||||||
|
m.no0 = m.n0 / (1.0 + delo)
|
||||||
|
m.ao = ao / (1.0 - delo)
|
||||||
|
m.a0 = m.ao
|
||||||
|
|
||||||
|
// Perigee altitude
|
||||||
|
perigeeKm := (m.ao*(1.0-m.e0) - 1.0) * sgp4Xkmper
|
||||||
|
m.isImpSimple = false
|
||||||
|
if perigeeKm < 220.0 {
|
||||||
|
m.isImpSimple = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s4 := sgp4So/sgp4Xkmper + 1.0
|
||||||
|
qoms24 := math.Pow((sgp4Qo-sgp4So)/sgp4Xkmper, 4.0)
|
||||||
|
if perigeeKm < 156.0 {
|
||||||
|
s4 = perigeeKm - 78.0
|
||||||
|
if perigeeKm <= 98.0 {
|
||||||
|
s4 = 20.0
|
||||||
|
}
|
||||||
|
qoms24 = math.Pow((sgp4Qo-s4)/sgp4Xkmper, 4.0)
|
||||||
|
s4 = s4/sgp4Xkmper + 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pinv := 1.0 / (m.ao * m.ao * m.beta04)
|
||||||
|
tsi := 1.0 / (m.ao - s4)
|
||||||
|
m.eta = m.ao * m.e0 * tsi
|
||||||
|
etasq := m.eta * m.eta
|
||||||
|
eeta := m.e0 * m.eta
|
||||||
|
psisq := math.Abs(1.0 - etasq)
|
||||||
|
coef := qoms24 * math.Pow(tsi, 4.0)
|
||||||
|
coef1 := coef / math.Pow(psisq, 3.5)
|
||||||
|
|
||||||
|
m.c2 = coef1 * m.no0 * (m.ao*(1.0+1.5*etasq+eeta*(4.0+etasq)) +
|
||||||
|
0.75*sgp4Ck2*tsi/psisq*(8.0+3.0*etasq*(8.0+etasq))*(3.0*m.theta2-1.0))
|
||||||
|
m.c1 = m.bstar * m.c2
|
||||||
|
m.c3 = coef * tsi * (-2.0 * sgp4Xj3 / sgp4Ck2) * m.no0 * m.sini0 / m.e0
|
||||||
|
if m.e0 < 1e-4 {
|
||||||
|
m.c3 = 0.0
|
||||||
|
}
|
||||||
|
m.c4 = 2.0 * m.no0 * coef1 * m.ao * m.beta02 * (m.eta*(2.0+0.5*etasq) +
|
||||||
|
m.e0*(0.5+2.0*etasq) -
|
||||||
|
2.0*sgp4Ck2*tsi/(m.ao*psisq)*(3.0*(1.0-3.0*m.theta2)*(1.0-2.0*eeta+etasq*(1.5-0.5*eeta))+
|
||||||
|
0.75*(1.0-m.theta2)*(2.0*etasq-eeta*(1.0+etasq))*math.Cos(2.0*m.w0)))
|
||||||
|
m.c5 = 2.0 * coef1 * m.ao * m.beta02 * (1.0 + 2.75*(etasq+eeta) + eeta*etasq)
|
||||||
|
|
||||||
|
// Secular rates
|
||||||
|
temp1 := 3.0 * sgp4Ck2 * pinv * m.no0
|
||||||
|
temp2 := temp1 * sgp4Ck2 * pinv
|
||||||
|
temp3 := 1.25 * sgp4Ck4 * pinv * pinv * m.no0
|
||||||
|
|
||||||
|
m.xmdot = m.no0 + 0.5*temp1*m.beta0*(3.0*m.theta2-1.0) +
|
||||||
|
0.0625*temp2*m.beta0*(13.0-78.0*m.theta2+137.0*m.theta4)
|
||||||
|
|
||||||
|
m.omgdot = -0.5*temp1*(1.0-5.0*m.theta2) +
|
||||||
|
0.0625*temp2*(7.0-114.0*m.theta2+395.0*m.theta4) +
|
||||||
|
temp3*(3.0-36.0*m.theta2+49.0*m.theta4)
|
||||||
|
|
||||||
|
m.xnodot = -temp1*m.cosi0 +
|
||||||
|
(0.5*temp2*(4.0-19.0*m.theta2)+2.0*temp3*(3.0-7.0*m.theta2))*m.cosi0
|
||||||
|
|
||||||
|
if !m.isImpSimple {
|
||||||
|
c1sq := m.c1 * m.c1
|
||||||
|
m.d2 = 4.0 * m.ao * tsi * c1sq
|
||||||
|
temp := m.d2 * tsi * m.c1 / 3.0
|
||||||
|
m.d3 = (17.0*m.ao + s4) * temp
|
||||||
|
m.d4 = 0.5 * temp * m.ao * tsi * (221.0*m.ao + 31.0*s4) * m.c1
|
||||||
|
m.t3cof = m.d2 + 2.0*c1sq
|
||||||
|
m.t4cof = 0.25 * (3.0*m.d3 + m.c1*(12.0*m.d2+10.0*c1sq))
|
||||||
|
m.t5cof = 0.2 * (3.0*m.d4 + 12.0*m.c1*m.d3 + 6.0*m.d2*m.d2 + 15.0*c1sq*(2.0*m.d2+c1sq))
|
||||||
|
|
||||||
|
m.omgcof = m.bstar * m.c3 * math.Cos(m.w0)
|
||||||
|
m.xmcof = 0.0
|
||||||
|
if m.e0 > 1e-4 && eeta > 0 {
|
||||||
|
m.xmcof = -2.0 / 3.0 * coef * m.bstar / eeta
|
||||||
|
}
|
||||||
|
m.delmo = math.Pow(1.0+m.eta*math.Cos(m.m0), 3.0)
|
||||||
|
m.sinmao = math.Sin(m.m0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PropagateMinutes calculates position (km) and velocity (km/s) in TEME at tsince minutes from epoch.
|
||||||
|
func (m *SGP4Model) PropagateMinutes(tsince float64) (Vector3, Vector3, error) {
|
||||||
|
// Update for secular gravity and atmospheric drag
|
||||||
|
xmdf := m.m0 + m.xmdot*tsince
|
||||||
|
omgadf := m.w0 + m.omgdot*tsince
|
||||||
|
xnoddf := m.om0 + m.xnodot*tsince
|
||||||
|
omega := omgadf
|
||||||
|
xmp := xmdf
|
||||||
|
tsq := tsince * tsince
|
||||||
|
xnode := xnoddf + 0.5*m.c1*tsq
|
||||||
|
tempa := 1.0 - m.c1*tsince
|
||||||
|
tempe := m.bstar * m.c4 * tsince
|
||||||
|
templ := m.t2cof * tsq
|
||||||
|
|
||||||
|
if !m.isImpSimple {
|
||||||
|
delomg := m.omgcof * tsince
|
||||||
|
delm := m.xmcof * (math.Pow(1.0+m.eta*math.Cos(xmdf), 3) - m.delmo)
|
||||||
|
temp := delomg + delm
|
||||||
|
xmp = xmdf + temp
|
||||||
|
omega = omgadf - temp
|
||||||
|
tcube := tsq * tsince
|
||||||
|
tfour := tsq * tsq
|
||||||
|
tempa = tempa - m.d2*tsq - m.d3*tcube - m.d4*tfour
|
||||||
|
tempe = tempe + m.bstar*m.c5*(math.Sin(xmp)-m.sinmao)
|
||||||
|
templ = templ + m.t3cof*tcube + tfour*(m.t4cof+tsince*m.t5cof)
|
||||||
|
}
|
||||||
|
|
||||||
|
a := m.ao * tempa * tempa
|
||||||
|
e := m.e0 - tempe
|
||||||
|
xl := xmp + omega + xnode + m.no0*templ
|
||||||
|
beta := math.Sqrt(1.0 - e*e)
|
||||||
|
n := sgp4Xke / math.Pow(a, 1.5)
|
||||||
|
|
||||||
|
// Long period periodic terms
|
||||||
|
axn := e * math.Cos(omega)
|
||||||
|
temp := 1.0 / (a * beta * beta)
|
||||||
|
xll := temp * 0.5 * sgp4Ck2 * 1.5 * (3.0*m.theta2 - 1.0)
|
||||||
|
ayn := e*math.Sin(omega) + temp*(-0.5*sgp4Xj3/sgp4Ck2*m.sini0)
|
||||||
|
xlt := xl - temp*0.25*(-0.5*sgp4Xj3/sgp4Ck2*m.sini0)*axn*(3.0+5.0*m.cosi0)/(1.0+m.cosi0)
|
||||||
|
|
||||||
|
// Solve Kepler's Equation for (U = E + w)
|
||||||
|
u := math.Mod(xlt-xnode, sgp4TwoPi)
|
||||||
|
if u < 0 {
|
||||||
|
u += sgp4TwoPi
|
||||||
|
}
|
||||||
|
eo1 := u
|
||||||
|
tem5 := 1.0
|
||||||
|
for iter := 0; iter < 10 && math.Abs(tem5) >= sgp4E6a; iter++ {
|
||||||
|
sineo1 := math.Sin(eo1)
|
||||||
|
coseo1 := math.Cos(eo1)
|
||||||
|
tem5 = 1.0 - coseo1*axn - sineo1*ayn
|
||||||
|
tem5 = (u - ayn*coseo1 + axn*sineo1 - eo1) / tem5
|
||||||
|
if math.Abs(tem5) > 1.0 {
|
||||||
|
if tem5 > 0 {
|
||||||
|
tem5 = 1.0
|
||||||
|
} else {
|
||||||
|
tem5 = -1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eo1 += tem5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short period preliminary quantities
|
||||||
|
sineo1 := math.Sin(eo1)
|
||||||
|
coseo1 := math.Cos(eo1)
|
||||||
|
ecose := axn*coseo1 + ayn*sineo1
|
||||||
|
esine := axn*sineo1 - ayn*coseo1
|
||||||
|
el2 := axn*axn + ayn*ayn
|
||||||
|
pl := a * (1.0 - el2)
|
||||||
|
if pl < 0 {
|
||||||
|
return Vector3{}, Vector3{}, errors.New("satellite decayed into Earth")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := a * (1.0 - ecose)
|
||||||
|
rdot := sgp4Xke * math.Sqrt(a) * esine / r
|
||||||
|
rfdot := sgp4Xke * math.Sqrt(pl) / r
|
||||||
|
betal := math.Sqrt(1.0 - el2)
|
||||||
|
temp = esine / (1.0 + betal)
|
||||||
|
sinu := a / r * (sineo1 - ayn - axn*temp)
|
||||||
|
cosu := a / r * (coseo1 - axn + ayn*temp)
|
||||||
|
su := math.Atan2(sinu, cosu)
|
||||||
|
sin2u := 2.0 * sinu * cosu
|
||||||
|
cos2u := 2.0*cosu*cosu - 1.0
|
||||||
|
temp = 1.0 / pl
|
||||||
|
temp1 := sgp4Ck2 * temp
|
||||||
|
temp2 := temp1 * temp
|
||||||
|
|
||||||
|
// Update for short periodics
|
||||||
|
rk := r*(1.0-1.5*temp2*betal*(3.0*m.theta2-1.0)) + 0.5*temp1*(1.0-m.theta2)*cos2u
|
||||||
|
uk := su - 0.25*temp2*(7.0*m.theta2-1.0)*sin2u
|
||||||
|
xnodek := xnode + 1.5*temp2*m.cosi0*sin2u
|
||||||
|
xinck := m.i0 + 1.5*temp2*m.cosi0*m.sini0*cos2u
|
||||||
|
rdotk := rdot - n*temp1*(1.0-m.theta2)*sin2u
|
||||||
|
rfdotk := rfdot + n*temp1*((1.0-m.theta2)*cos2u+1.5*(3.0*m.theta2-1.0))
|
||||||
|
|
||||||
|
// Orientation vectors in TEME
|
||||||
|
sinuk := math.Sin(uk)
|
||||||
|
cosuk := math.Cos(uk)
|
||||||
|
sinik := math.Sin(xinck)
|
||||||
|
cosik := math.Cos(xinck)
|
||||||
|
sinnok := math.Sin(xnodek)
|
||||||
|
cosnok := math.Cos(xnodek)
|
||||||
|
|
||||||
|
mx := -sinnok * cosik
|
||||||
|
my := cosnok * cosik
|
||||||
|
ux := mx*sinuk + cosnok*cosuk
|
||||||
|
uy := my*sinuk + sinnok*cosuk
|
||||||
|
uz := sinik * sinuk
|
||||||
|
vx := mx*cosuk - cosnok*sinuk
|
||||||
|
vy := my*cosuk - sinnok*sinuk
|
||||||
|
vz := sinik * cosuk
|
||||||
|
|
||||||
|
// Position and velocity in km and km/s
|
||||||
|
pos := Vector3{
|
||||||
|
X: rk * ux * sgp4Xkmper,
|
||||||
|
Y: rk * uy * sgp4Xkmper,
|
||||||
|
Z: rk * uz * sgp4Xkmper,
|
||||||
|
}
|
||||||
|
|
||||||
|
vel := Vector3{
|
||||||
|
X: (rdotk*ux + rfdotk*vx) * sgp4Xkmper / 60.0,
|
||||||
|
Y: (rdotk*uy + rfdotk*vy) * sgp4Xkmper / 60.0,
|
||||||
|
Z: (rdotk*uz + rfdotk*vz) * sgp4Xkmper / 60.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = xll
|
||||||
|
return pos, vel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional helper field values
|
||||||
|
var (
|
||||||
|
_ = sgp4Xj4
|
||||||
|
)
|
||||||
|
|
||||||
|
// PropagateTime calculates position (km) and velocity (km/s) in TEME at an absolute UTC time.
|
||||||
|
func (m *SGP4Model) PropagateTime(t time.Time) (Vector3, Vector3, error) {
|
||||||
|
tsince := t.Sub(m.TLE.EpochTime).Minutes()
|
||||||
|
return m.PropagateMinutes(tsince)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionECEF calculates ECEF coordinates at time t.
|
||||||
|
func (m *SGP4Model) PositionECEF(t time.Time) (Vector3, Vector3, error) {
|
||||||
|
posTEME, velTEME, err := m.PropagateTime(t)
|
||||||
|
if err != nil {
|
||||||
|
return Vector3{}, Vector3{}, err
|
||||||
|
}
|
||||||
|
jd := JulianDate(t)
|
||||||
|
posECEF := TEMEtoECEF(posTEME, jd)
|
||||||
|
// Approximate velocity in ECEF
|
||||||
|
velECEF := TEMEtoECEF(velTEME, jd)
|
||||||
|
return posECEF, velECEF, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeodeticPosition calculates WGS-84 Geodetic position (lat, lon, alt) at time t.
|
||||||
|
func (m *SGP4Model) GeodeticPosition(t time.Time) (Geodetic, Vector3, error) {
|
||||||
|
posECEF, velECEF, err := m.PositionECEF(t)
|
||||||
|
if err != nil {
|
||||||
|
return Geodetic{}, Vector3{}, err
|
||||||
|
}
|
||||||
|
geo := ECEFtoGeodetic(posECEF)
|
||||||
|
return geo, velECEF, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standard ISS TLE sample
|
||||||
|
const (
|
||||||
|
testISSName = "ISS (ZARYA)"
|
||||||
|
testISSLine1 = "1 25544U 98067A 24080.56944444 .00014524 00000-0 26388-3 0 9993"
|
||||||
|
testISSLine2 = "2 25544 51.6416 195.4568 0004928 120.1234 239.9876 15.49876543445566"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTLE(t *testing.T) {
|
||||||
|
tle, err := ParseTLE(testISSName, testISSLine1, testISSLine2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTLE failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tle.NoradID != 25544 {
|
||||||
|
t.Errorf("Expected NoradID 25544, got %d", tle.NoradID)
|
||||||
|
}
|
||||||
|
if math.Abs(tle.InclinationDeg-51.6416) > 1e-4 {
|
||||||
|
t.Errorf("Expected Inclination 51.6416, got %f", tle.InclinationDeg)
|
||||||
|
}
|
||||||
|
if math.Abs(tle.Eccentricity-0.0004928) > 1e-6 {
|
||||||
|
t.Errorf("Expected Eccentricity 0.0004928, got %f", tle.Eccentricity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSGP4Propagation(t *testing.T) {
|
||||||
|
tle, err := ParseTLE(testISSName, testISSLine1, testISSLine2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTLE failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := NewSGP4Model(tle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSGP4Model failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate at epoch (tsince = 0)
|
||||||
|
pos, vel, err := model.PropagateMinutes(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PropagateMinutes(0) failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rKm := math.Sqrt(pos.X*pos.X + pos.Y*pos.Y + pos.Z*pos.Z)
|
||||||
|
// ISS orbit radius should be around 6700 - 6850 km (altitude 350 - 450 km)
|
||||||
|
if rKm < 6650 || rKm > 6900 {
|
||||||
|
t.Errorf("Unrealistic orbital radius: %f km", rKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
vKmS := math.Sqrt(vel.X*vel.X + vel.Y*vel.Y + vel.Z*vel.Z)
|
||||||
|
// Orbital velocity for LEO should be around 7.5 - 7.8 km/s
|
||||||
|
if vKmS < 7.2 || vKmS > 8.0 {
|
||||||
|
t.Errorf("Unrealistic orbital velocity: %f km/s", vKmS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate 90 minutes (about 1 orbit)
|
||||||
|
geo, _, err := model.GeodeticPosition(tle.EpochTime.Add(90 * time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GeodeticPosition failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if geo.Altitude < 350 || geo.Altitude > 460 {
|
||||||
|
t.Errorf("Unrealistic altitude after 90 min: %f km", geo.Altitude)
|
||||||
|
}
|
||||||
|
if geo.Latitude < -55 || geo.Latitude > 55 {
|
||||||
|
t.Errorf("Latitude %f exceeds inclination range +/-52 deg by too much", geo.Latitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookAnglesAndSun(t *testing.T) {
|
||||||
|
// Observer in San Francisco
|
||||||
|
obsSF := Geodetic{
|
||||||
|
Latitude: 37.7749,
|
||||||
|
Longitude: -122.4194,
|
||||||
|
Altitude: 0.03, // 30 meters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target directly above observer at 400km altitude
|
||||||
|
targetECEF := GeodeticToECEF(Geodetic{
|
||||||
|
Latitude: 37.7749,
|
||||||
|
Longitude: -122.4194,
|
||||||
|
Altitude: 400.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
angles := CalculateLookAngles(obsSF, targetECEF)
|
||||||
|
if math.Abs(angles.Elevation-90.0) > 0.1 {
|
||||||
|
t.Errorf("Expected elevation ~90 deg for overhead satellite, got %f", angles.Elevation)
|
||||||
|
}
|
||||||
|
if math.Abs(angles.Range-400.0) > 1.0 {
|
||||||
|
t.Errorf("Expected range ~400 km, got %f", angles.Range)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Sun elevation at noon vs midnight
|
||||||
|
noonUTC := time.Date(2024, 6, 21, 20, 0, 0, 0, time.UTC) // roughly solar noon in SF (12:00 PDT)
|
||||||
|
elNoon := ObserverSunElevation(obsSF, noonUTC)
|
||||||
|
if elNoon < 60.0 {
|
||||||
|
t.Errorf("Expected high solar elevation at summer solstice noon, got %f", elNoon)
|
||||||
|
}
|
||||||
|
|
||||||
|
midnightUTC := time.Date(2024, 6, 21, 8, 0, 0, 0, time.UTC) // midnight in SF
|
||||||
|
elMidnight := ObserverSunElevation(obsSF, midnightUTC)
|
||||||
|
if elMidnight > -15.0 {
|
||||||
|
t.Errorf("Expected negative solar elevation at midnight, got %f", elMidnight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPredictPasses(t *testing.T) {
|
||||||
|
tle, err := ParseTLE(testISSName, testISSLine1, testISSLine2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTLE failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := NewSGP4Model(tle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSGP4Model failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
obs := Geodetic{
|
||||||
|
Latitude: 40.7128, // New York
|
||||||
|
Longitude: -74.0060,
|
||||||
|
Altitude: 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predict passes for 5 days starting at epoch
|
||||||
|
passes, err := PredictPasses(model, PredictPassesOptions{
|
||||||
|
Observer: obs,
|
||||||
|
StartTime: tle.EpochTime,
|
||||||
|
Days: 5,
|
||||||
|
MinElevationDeg: 10.0,
|
||||||
|
VisibleOnly: false,
|
||||||
|
StandardMag: -1.8,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PredictPasses failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(passes) == 0 {
|
||||||
|
t.Errorf("Expected at least a few passes over 5 days for ISS over NYC, got 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Found %d passes over 5 days", len(passes))
|
||||||
|
for i, p := range passes {
|
||||||
|
t.Logf("Pass #%d: Start=%s, MaxEl=%.1f deg (%s), End=%s, Dur=%s, Vis=%s, Mag=%.1f",
|
||||||
|
i+1, p.Start.TimeFormatted, p.Max.Elevation, p.Max.AzimuthCardinal,
|
||||||
|
p.End.TimeFormatted, p.DurationFormatted, p.Visibility, p.EstimatedMag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTiangongNYC(t *testing.T) {
|
||||||
|
entry, ok := DefaultCatalog.Get("tiangong")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Tiangong not in default catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := NewSGP4Model(entry.TLE)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSGP4Model failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
obsNYC := Geodetic{
|
||||||
|
Latitude: 40.7128,
|
||||||
|
Longitude: -74.0060,
|
||||||
|
Altitude: 0.01,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. All passes
|
||||||
|
allPasses, err := PredictPasses(model, PredictPassesOptions{
|
||||||
|
Observer: obsNYC,
|
||||||
|
StartTime: time.Now().UTC(),
|
||||||
|
Days: 10,
|
||||||
|
MinElevationDeg: 10.0,
|
||||||
|
VisibleOnly: false,
|
||||||
|
StandardMag: -1.0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PredictPasses failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("Tiangong over NYC - ALL passes: %d", len(allPasses))
|
||||||
|
for i, p := range allPasses {
|
||||||
|
t.Logf("All pass #%d: Start=%s, MaxEl=%.1f, Vis=%s, Mag=%.1f", i+1, p.Start.Time.Format(time.RFC3339), p.Max.Elevation, p.Visibility, p.EstimatedMag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Visible passes only
|
||||||
|
visPasses, _ := PredictPasses(model, PredictPassesOptions{
|
||||||
|
Observer: obsNYC,
|
||||||
|
StartTime: time.Now().UTC(),
|
||||||
|
Days: 10,
|
||||||
|
MinElevationDeg: 10.0,
|
||||||
|
VisibleOnly: true,
|
||||||
|
StandardMag: -1.0,
|
||||||
|
})
|
||||||
|
t.Logf("Tiangong over NYC - VISIBLE ONLY passes: %d", len(visPasses))
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SunPositionECEF returns the position vector of the Sun in ECEF coordinates (km) for a given Julian Date.
|
||||||
|
func SunPositionECEF(jd float64) Vector3 {
|
||||||
|
d := jd - 2451545.0 // days from J2000.0
|
||||||
|
|
||||||
|
// Mean anomaly of the Sun
|
||||||
|
g := (357.529 + 0.98560028*d) * Deg2Rad
|
||||||
|
// Mean longitude of the Sun
|
||||||
|
q := 280.459 + 0.98564736*d
|
||||||
|
// Geocentric apparent ecliptic longitude
|
||||||
|
l := (q + 1.915*math.Sin(g) + 0.020*math.Sin(2*g)) * Deg2Rad
|
||||||
|
// Obliquity of ecliptic
|
||||||
|
e := (23.439 - 0.00000036*d) * Deg2Rad
|
||||||
|
|
||||||
|
// Distance to Sun in AU
|
||||||
|
rAU := 1.00014 - 0.01671*math.Cos(g) - 0.00014*math.Cos(2*g)
|
||||||
|
rKm := rAU * 149597870.7 // km
|
||||||
|
|
||||||
|
// Sun position in Equatorial coordinates (ECI)
|
||||||
|
sinL := math.Sin(l)
|
||||||
|
cosL := math.Cos(l)
|
||||||
|
cosE := math.Cos(e)
|
||||||
|
sinE := math.Sin(e)
|
||||||
|
|
||||||
|
sunECI := Vector3{
|
||||||
|
X: rKm * cosL,
|
||||||
|
Y: rKm * cosE * sinL,
|
||||||
|
Z: rKm * sinE * sinL,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate ECI to ECEF by GMST
|
||||||
|
return TEMEtoECEF(sunECI, jd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserverSunElevation calculates the Sun's elevation angle (in degrees) for an observer location at time t.
|
||||||
|
func ObserverSunElevation(obs Geodetic, t time.Time) float64 {
|
||||||
|
jd := JulianDate(t)
|
||||||
|
sunECEF := SunPositionECEF(jd)
|
||||||
|
angles := CalculateLookAngles(obs, sunECEF)
|
||||||
|
return angles.Elevation
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSatelliteIlluminated determines if a satellite at satECEF is illuminated by the Sun (not eclipsed by Earth).
|
||||||
|
// Uses a cylindrical Earth shadow model with EarthRadiusKm.
|
||||||
|
func IsSatelliteIlluminated(satECEF Vector3, jd float64) bool {
|
||||||
|
sunECEF := SunPositionECEF(jd)
|
||||||
|
|
||||||
|
// Vector from Earth center to Sun (unit vector)
|
||||||
|
sunDist := math.Sqrt(sunECEF.X*sunECEF.X + sunECEF.Y*sunECEF.Y + sunECEF.Z*sunECEF.Z)
|
||||||
|
ux := sunECEF.X / sunDist
|
||||||
|
uy := sunECEF.Y / sunDist
|
||||||
|
uz := sunECEF.Z / sunDist
|
||||||
|
|
||||||
|
// Projection of satellite vector along Sun direction
|
||||||
|
dot := satECEF.X*ux + satECEF.Y*uy + satECEF.Z*uz
|
||||||
|
|
||||||
|
// If satellite is on day side of Earth (dot >= 0), it is illuminated
|
||||||
|
if dot >= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Satellite is on night side of Earth (dot < 0).
|
||||||
|
// Distance squared from shadow axis:
|
||||||
|
satDistSq := satECEF.X*satECEF.X + satECEF.Y*satECEF.Y + satECEF.Z*satECEF.Z
|
||||||
|
distFromAxisSq := satDistSq - dot*dot
|
||||||
|
|
||||||
|
// If distance from Earth shadow axis is greater than Earth radius, satellite is in sunlight
|
||||||
|
return distFromAxisSq > (EarthRadiusKm * EarthRadiusKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateVisualMagnitude estimates apparent visual magnitude for a satellite.
|
||||||
|
// standardMag: intrinsic magnitude at 1000 km distance and 50% illumination (e.g. -1.3 for ISS, ~5.0 for typical cube/small sat).
|
||||||
|
func EstimateVisualMagnitude(standardMag float64, satECEF, obsECEF Vector3, jd float64) float64 {
|
||||||
|
sunECEF := SunPositionECEF(jd)
|
||||||
|
|
||||||
|
// Satellite to Observer vector
|
||||||
|
dx := obsECEF.X - satECEF.X
|
||||||
|
dy := obsECEF.Y - satECEF.Y
|
||||||
|
dz := obsECEF.Z - satECEF.Z
|
||||||
|
rangeKm := math.Sqrt(dx*dx + dy*dy + dz*dz)
|
||||||
|
if rangeKm < 1.0 {
|
||||||
|
rangeKm = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Satellite to Sun vector
|
||||||
|
sx := sunECEF.X - satECEF.X
|
||||||
|
sy := sunECEF.Y - satECEF.Y
|
||||||
|
sz := sunECEF.Z - satECEF.Z
|
||||||
|
sunDist := math.Sqrt(sx*sx + sy*sy + sz*sz)
|
||||||
|
|
||||||
|
// Cosine of phase angle (angle between Sun and Observer as seen from Satellite)
|
||||||
|
cosPhase := (dx*sx + dy*sy + dz*sz) / (rangeKm * sunDist)
|
||||||
|
if cosPhase > 1.0 {
|
||||||
|
cosPhase = 1.0
|
||||||
|
} else if cosPhase < -1.0 {
|
||||||
|
cosPhase = -1.0
|
||||||
|
}
|
||||||
|
phaseAngle := math.Acos(cosPhase)
|
||||||
|
|
||||||
|
// Phase function (diffuse sphere approximation):
|
||||||
|
// phi(beta) = (sin(beta) + (pi - beta)*cos(beta)) / pi
|
||||||
|
sinP := math.Sin(phaseAngle)
|
||||||
|
cosP := math.Cos(phaseAngle)
|
||||||
|
phi := (sinP + (math.Pi-phaseAngle)*cosP) / math.Pi
|
||||||
|
if phi <= 0.001 {
|
||||||
|
phi = 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apparent magnitude:
|
||||||
|
// m = M_std - 15.75 + 5 * log10(range) - 2.5 * log10(phi)
|
||||||
|
// (Normalized so standardMag is roughly apparent mag at 1000km range and optimal phase)
|
||||||
|
mag := standardMag + 5.0*math.Log10(rangeKm/1000.0) - 2.5*math.Log10(phi)
|
||||||
|
return math.Round(mag*10.0) / 10.0
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package orbitviewer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"toolbox/pkg/base"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrbitViewerTool struct{}
|
||||||
|
|
||||||
|
func (t *OrbitViewerTool) ID() string {
|
||||||
|
return "orbit-viewer"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OrbitViewerTool) Name() string {
|
||||||
|
return "卫星轨道观测与AR定位"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OrbitViewerTool) Description() string {
|
||||||
|
return "实时卫星轨迹模拟、未来10天最佳观测窗口预测及移动端AR实景指天找星工具。"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OrbitViewerTool) Emoji() string {
|
||||||
|
return "🛰️"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OrbitViewerTool) Init() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OrbitViewerTool) RegisterRoutes(r *gin.RouterGroup) {
|
||||||
|
RegisterRoutes(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
base.Register(&OrbitViewerTool{})
|
||||||
|
}
|
||||||
+62
-62
@@ -340,70 +340,70 @@ func parseSVGPath(path string, scale, ox, oy float64, flipY bool) []gopdf.Point
|
|||||||
var pts []gopdf.Point
|
var pts []gopdf.Point
|
||||||
matches := reSVG.FindAllStringSubmatch(path, -1)
|
matches := reSVG.FindAllStringSubmatch(path, -1)
|
||||||
var lastX, lastY float64
|
var lastX, lastY float64
|
||||||
for idx := 0; idx < len(matches); {
|
for idx := 0; idx < len(matches); {
|
||||||
item := matches[idx][0]
|
item := matches[idx][0]
|
||||||
switch item {
|
switch item {
|
||||||
case "M", "L":
|
case "M", "L":
|
||||||
if idx+2 < len(matches) {
|
if idx+2 < len(matches) {
|
||||||
x, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
x, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
||||||
y, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
y, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
lastX, lastY = x, y
|
lastX, lastY = x, y
|
||||||
if flipY {
|
if flipY {
|
||||||
y = 1024 - y
|
y = 1024 - y
|
||||||
}
|
|
||||||
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
|
||||||
idx += 3
|
|
||||||
} else {
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
case "C":
|
|
||||||
if idx+6 < len(matches) {
|
|
||||||
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
|
||||||
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
|
||||||
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64)
|
|
||||||
y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
|
||||||
x, _ := strconv.ParseFloat(matches[idx+5][0], 64)
|
|
||||||
y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
|
|
||||||
for t := 0.2; t <= 1.0; t += 0.2 {
|
|
||||||
invT := 1 - t
|
|
||||||
tx := invT*invT*invT*lastX + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
|
|
||||||
ty := invT*invT*invT*lastY + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
|
|
||||||
ty_f := ty
|
|
||||||
if flipY {
|
|
||||||
ty_f = 1024 - ty
|
|
||||||
}
|
|
||||||
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
|
||||||
}
|
|
||||||
lastX, lastY = x, y
|
|
||||||
idx += 7
|
|
||||||
} else {
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
case "Q":
|
|
||||||
if idx+4 < len(matches) {
|
|
||||||
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
|
||||||
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
|
||||||
x, _ := strconv.ParseFloat(matches[idx+3][0], 64)
|
|
||||||
y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
|
||||||
for t := 0.2; t <= 1.0; t += 0.2 {
|
|
||||||
invT := 1 - t
|
|
||||||
tx := invT*invT*lastX + 2*invT*t*x1 + t*t*x
|
|
||||||
ty := invT*invT*lastY + 2*invT*t*y1 + t*t*y
|
|
||||||
ty_f := ty
|
|
||||||
if flipY {
|
|
||||||
ty_f = 1024 - ty
|
|
||||||
}
|
|
||||||
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
|
||||||
}
|
|
||||||
lastX, lastY = x, y
|
|
||||||
idx += 5
|
|
||||||
} else {
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
idx++
|
|
||||||
}
|
}
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
|
||||||
|
idx += 3
|
||||||
|
} else {
|
||||||
|
idx++
|
||||||
}
|
}
|
||||||
|
case "C":
|
||||||
|
if idx+6 < len(matches) {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
||||||
|
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
|
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64)
|
||||||
|
y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||||
|
x, _ := strconv.ParseFloat(matches[idx+5][0], 64)
|
||||||
|
y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
|
||||||
|
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||||
|
invT := 1 - t
|
||||||
|
tx := invT*invT*invT*lastX + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
|
||||||
|
ty := invT*invT*invT*lastY + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
|
||||||
|
ty_f := ty
|
||||||
|
if flipY {
|
||||||
|
ty_f = 1024 - ty
|
||||||
|
}
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
||||||
|
}
|
||||||
|
lastX, lastY = x, y
|
||||||
|
idx += 7
|
||||||
|
} else {
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
case "Q":
|
||||||
|
if idx+4 < len(matches) {
|
||||||
|
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
|
||||||
|
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
|
||||||
|
x, _ := strconv.ParseFloat(matches[idx+3][0], 64)
|
||||||
|
y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
|
||||||
|
for t := 0.2; t <= 1.0; t += 0.2 {
|
||||||
|
invT := 1 - t
|
||||||
|
tx := invT*invT*lastX + 2*invT*t*x1 + t*t*x
|
||||||
|
ty := invT*invT*lastY + 2*invT*t*y1 + t*t*y
|
||||||
|
ty_f := ty
|
||||||
|
if flipY {
|
||||||
|
ty_f = 1024 - ty
|
||||||
|
}
|
||||||
|
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
|
||||||
|
}
|
||||||
|
lastX, lastY = x, y
|
||||||
|
idx += 5
|
||||||
|
} else {
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return pts
|
return pts
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user