package sat import ( "math" "testing" "time" ) // 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" ) 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") } }