feat(sat): the sky engine — elements, look angles, passes, Doppler
The foundation of the satellite branch, and nothing above it yet: where a satellite is (SGP4 from akhenakh/sgp4, Apache-2.0 and pure Go, so the no-cgo rule holds), where it will be (passes with an elevation floor, because a three-degree scrape is a line in a table that will never be a QSO), and what its motion does to a frequency. The Doppler pair is the part worth being careful about: the two corrections go in OPPOSITE directions. The downlink arrives shifted and we tune to meet it; the uplink must LEAVE shifted the other way to land on the transponder's nominal input. A test pins the signs and the size — 7 km/s on 2 m is about 3.4 kHz. Elements keep their raw lines beside the parsed form: that is what the cache stores and what an operator pastes by hand for a bird no feed carries yet, which is exactly when everyone wants to hear it.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user