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:
2026-09-07 10:36:56 +02:00
parent b0f76a8ba1
commit 2d71351080
4 changed files with 547 additions and 0 deletions
+172
View File
@@ -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")
}
}