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

200 lines
5.5 KiB
Go

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))
}