package sat import ( "math" "testing" "time" "github.com/akhenakh/sgp4" ) // A real ISS element set, and the answers a second tracker agrees with. The // point is not the third decimal — it is that the observer, the epoch and the // look angle are wired the right way round, which is exactly what silently // comes out mirrored or an hour late. const ( issName = "ISS (ZARYA)" issLine1 = "1 25544U 98067A 24298.54791435 .00016717 00000+0 30074-3 0 9991" issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227" ) // testLoc is the same observer, in the form the internal range helper takes. var testLoc = sgp4.Location{Latitude: 48.5, Longitude: 3.0} func issElement(t *testing.T) Element { t.Helper() e, err := ParseElement(issName, issLine1, issLine2) if err != nil { t.Fatalf("parse: %v", err) } return e } func TestElementCarriesItsIdentityAndEpoch(t *testing.T) { e := issElement(t) if e.NORAD != 25544 { t.Errorf("NORAD = %d, want 25544", e.NORAD) } // Day 298.548 of 2024 — the day the elements were computed. want := time.Date(2024, 10, 24, 13, 9, 0, 0, time.UTC) if d := e.Epoch.Sub(want); d > time.Minute || d < -time.Minute { t.Errorf("epoch = %s, want about %s", e.Epoch.Format(time.RFC3339), want.Format(time.RFC3339)) } } // The satellite is somewhere, that somewhere is on Earth's scale, and the look // angles are self-consistent: a bird below the horizon is further away than one // overhead, and the footprint is a plausible circle. func TestTrackIsSaneFromAKnownStation(t *testing.T) { e := issElement(t) obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35} // JN18, Paris at := time.Date(2024, 10, 24, 14, 0, 0, 0, time.UTC) p, err := e.Track(obs, at) if err != nil { t.Fatalf("track: %v", err) } if p.Lat < -90 || p.Lat > 90 || p.Lon < -180 || p.Lon > 180 { t.Errorf("sub-satellite point off the planet: %.3f %.3f", p.Lat, p.Lon) } if p.AltKm < 300 || p.AltKm > 500 { t.Errorf("altitude %.1f km — the ISS is not there", p.AltKm) } if p.Az < 0 || p.Az >= 360 || p.El < -90 || p.El > 90 { t.Errorf("look angles out of range: az %.1f el %.1f", p.Az, p.El) } // A satellite on the FAR side of the planet is still at a distance — up to // two Earth radii plus its height — so the useful invariant is the one that // holds when it is actually up: above the horizon it cannot be further away // than the slant range to its own footprint edge. if p.RangeKm < 300 || p.RangeKm > 13200 { t.Errorf("range %.0f km is not this orbit seen from the ground", p.RangeKm) } if p.El > 0 && p.RangeKm > 2600 { t.Errorf("visible at %.1f° yet %.0f km away", p.El, p.RangeKm) } // ~2000 km of visibility circle at 420 km up. if p.Footprint < 1500 || p.Footprint > 2600 { t.Errorf("footprint %.0f km", p.Footprint) } } // Twelve hours of ISS passes over a European station: there are always several, // they rise before they set, and the filter keeps its promise. func TestPassesRiseBeforeTheySetAndRespectTheFloor(t *testing.T) { s := NewStore() s.Put(issElement(t)) obs := Observer{Lat: 48.85, Lon: 2.35, AltM: 35} from := time.Date(2024, 10, 24, 12, 0, 0, 0, time.UTC) all, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 0) if err != nil { t.Fatalf("passes: %v", err) } if len(all) == 0 { t.Fatal("no ISS pass in twelve hours over Paris") } for _, p := range all { if !p.LOS.After(p.AOS) { t.Errorf("%s: sets (%s) before it rises (%s)", p.Name, p.LOS, p.AOS) } if p.MaxEl <= 0 || p.MaxEl > 90 { t.Errorf("max elevation %.1f", p.MaxEl) } if p.MaxElAt.Before(p.AOS) || p.MaxElAt.After(p.LOS) { t.Errorf("the highest point falls outside the pass") } } high, err := s.Passes(issName, obs, from, from.Add(12*time.Hour), 30) if err != nil { t.Fatalf("passes: %v", err) } if len(high) > len(all) { t.Error("the elevation floor let MORE passes through") } for _, p := range high { if p.MaxEl < 30 { t.Errorf("a %.1f° pass survived a 30° floor", p.MaxEl) } } } // The two corrections go in OPPOSITE directions, and that is the whole of it: // the downlink arrives shifted so we tune to meet it, while the uplink has to // leave shifted the other way to land on the transponder's nominal input. func TestDopplerCorrectsBothWaysRoundTheRightWay(t *testing.T) { const down, up = 145_950_000, 435_250_000 approaching := Position{RangeRate: -7.0} // km/s, coming towards us receding := Position{RangeRate: +7.0} a := Doppler(approaching, down, up) if a.DownHz <= down { t.Errorf("approaching: listen at %d, expected above %d", a.DownHz, down) } if a.UpHz >= up { t.Errorf("approaching: transmit at %d, expected below %d", a.UpHz, up) } r := Doppler(receding, down, up) if r.DownHz >= down { t.Errorf("receding: listen at %d, expected below %d", r.DownHz, down) } if r.UpHz <= up { t.Errorf("receding: transmit at %d, expected above %d", r.UpHz, up) } // Size, not just sign: 7 km/s on 145.950 MHz is about 3.4 kHz. if d := math.Abs(float64(a.DownHz - down)); d < 3000 || d > 3800 { t.Errorf("shift of %.0f Hz on 2 m at 7 km/s", d) } // Stationary is untouched, and an absent uplink is not invented. if s := Doppler(Position{}, down, 0); s.DownHz != down || s.UpHz != 0 { t.Errorf("a still satellite was corrected: %+v", s) } } func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) { s := NewStore() e := issElement(t) at := time.Date(2026, 9, 7, 10, 0, 0, 0, time.UTC) s.Replace([]Element{e}, at) if s.Len() != 1 || s.Names()[0] != issName { t.Errorf("store holds %v", s.Names()) } if !s.FetchedAt().Equal(at) { t.Errorf("fetched at %s", s.FetchedAt()) } // Case and spacing vary between feeds and typists; the name is not a // password. if _, ok := s.Get("iss (zarya)"); !ok { t.Error("a satellite could not be found under its own name in another case") } if _, err := s.Track("NOTHING", Observer{}, at); err == nil { t.Error("an unknown satellite was tracked anyway") } } // The range rate is the whole of the Doppler shift, and it was wrong in both // magnitude and sign — the propagator library reported +2036 km/s for an ISS // that was closing at 5.5, which moved the correction hundreds of kilohertz the // wrong way. These are the two things about it that cannot be argued with. func TestRangeRateIsPhysical(t *testing.T) { e := issElement(t) obs := Observer{Lat: 48.5, Lon: 3.0} // A day's worth, sampled across every geometry a pass goes through. base := e.Epoch.Add(2 * time.Hour) for i := 0; i < 240; i++ { at := base.Add(time.Duration(i) * 6 * time.Minute) p, err := e.Track(obs, at) if err != nil { t.Fatalf("track: %v", err) } // Nothing in low earth orbit closes faster than it flies, and it flies // at about 7.7 km/s. A figure outside this is a units mistake. if math.Abs(p.RangeRate) > 8 { t.Fatalf("%s: range rate %.1f km/s — faster than orbital velocity", at.Format(time.RFC3339), p.RangeRate) } // And it must be the derivative of the range we display, sign included. before, _ := e.rangeAt(&testLoc, at.Add(-2*time.Second)) after, _ := e.rangeAt(&testLoc, at.Add(2*time.Second)) want := (after - before) / 4 if math.Abs(p.RangeRate-want) > 0.05 { t.Errorf("%s: range rate %.3f but the range moves at %.3f km/s", at.Format(time.RFC3339), p.RangeRate, want) } } } // The Doppler that comes out of it, on the two bands satellites are worked on. // A LEO gives about ±3.5 kHz on 2 m and ±10 kHz on 70 cm; ten times either is // the bug this pins. func TestDopplerStaysWithinTheTextbookRange(t *testing.T) { e := issElement(t) obs := Observer{Lat: 48.5, Lon: 3.0} base := e.Epoch.Add(2 * time.Hour) var maxVHF, maxUHF int64 for i := 0; i < 480; i++ { p, err := e.Track(obs, base.Add(time.Duration(i)*3*time.Minute)) if err != nil { continue } vhf := Doppler(p, 145_800_000, 0).DownHz - 145_800_000 uhf := Doppler(p, 437_800_000, 0).DownHz - 437_800_000 if a := abs64(vhf); a > maxVHF { maxVHF = a } if a := abs64(uhf); a > maxUHF { maxUHF = a } } if maxVHF < 1_500 || maxVHF > 5_000 { t.Errorf("2 m Doppler peaks at %d Hz, expected roughly 3.5 kHz", maxVHF) } if maxUHF < 5_000 || maxUHF > 14_000 { t.Errorf("70 cm Doppler peaks at %d Hz, expected roughly 10 kHz", maxUHF) } } func abs64(v int64) int64 { if v < 0 { return -v } return v }