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