Reported as "the Doppler moves the frequency enormously", and it did: a 2 m
downlink was being shifted two megahertz across a pass instead of three
kilohertz, in the wrong direction.
The propagator library reports a range rate that is not one. Measured against
the range it is meant to be the derivative of, on this station's own cached
elements:
PO-101 library -1457.3 km/s measured +6.227 km/s
ISS library +2036.8 km/s measured -5.522 km/s
Wrong by a factor of some 250 and of the wrong sign, so the correction both
overshot and pushed the operator away from the station they could hear. Nothing
else was affected — the elevation and the passes come from the look angle, which
is right — which is why this survived: the satellite was in the correct place on
the map while the radio was told to go megahertz away from it.
So OpsLog computes it itself, as the difference between two ranges a second
apart. That cannot be wrong in either magnitude or sign: it differentiates the
very number the panel displays. Two extra propagations per call, which is
microseconds.
Two tests pin it, and both fail against the old behaviour: a range rate faster
than orbital velocity is a units mistake, and the Doppler on the two bands
satellites are worked on has a textbook size — about ±3.5 kHz on 2 m, ±10 kHz on
70 cm.
cmd/satdiag is the throwaway that found it, kept because the next report of this
shape ("the frequency moves oddly", "that pass is not real") is answered by the
same three numbers: which elements the satellite resolved to, the range rate
reported against the range rate measured, and the Doppler each transponder gets.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
246 lines
8.2 KiB
Go
246 lines
8.2 KiB
Go
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
|
|
}
|