feat(sat): element feeds and the frequency plan
Two things the tracker cannot work without, both kept apart from the orbital maths on purpose. The elements come from Celestrak's amateur group, with PE0SAT as the fallback for the hour when Celestrak is rate-limiting a hundred trackers at once. A malformed satellite is skipped rather than fatal — a feed of two hundred birds with one bad checksum must still give the operator the other hundred and ninety-nine — and the count is returned so the app can say so. The cache is plain TLE text in the data directory, written beside and renamed, and only replaced once a feed has produced usable elements: a captive portal must not take away the set the station already had. Loading it first is what makes the satellite tab full on a shack PC with no internet. The frequency plan is separate because it changes for different reasons: elements every few days from a feed, a transponder when the satellite is commanded into another mode. The shipped list is a starting point, copied to the data directory on first use and read from there afterwards, so an operator can correct a frequency without waiting for a release and keep the correction across updates — and a file they have broken is reported, not overwritten. UplinkFor is the part that matters on the air. On an inverting linear transponder, tuning up the downlink means going down the uplink; get it backwards and you transmit at the far end of the passband from the station you can hear, which is the classic first evening on a linear bird. Names are matched on letters and digits alone. Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, and nobody spells Es'hail the same way twice.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package sat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The shipped list has to be readable and consistent — it is embedded, so a
|
||||
// mistake in it is a mistake in every build.
|
||||
func TestShippedBirds(t *testing.T) {
|
||||
b := &Birds{}
|
||||
if err := b.parse(shippedBirds); err != nil {
|
||||
t.Fatalf("birds.json does not parse: %v", err)
|
||||
}
|
||||
if b.Len() < 5 {
|
||||
t.Fatalf("only %d satellites shipped", b.Len())
|
||||
}
|
||||
for _, bird := range b.All() {
|
||||
if len(bird.Transponders) == 0 {
|
||||
t.Errorf("%s has no transponder", bird.Name)
|
||||
}
|
||||
for _, tr := range bird.Transponders {
|
||||
if tr.DownLo <= 0 {
|
||||
t.Errorf("%s / %s: no downlink", bird.Name, tr.Label)
|
||||
}
|
||||
if tr.DownHi != 0 && tr.DownHi <= tr.DownLo {
|
||||
t.Errorf("%s / %s: downlink passband runs backwards", bird.Name, tr.Label)
|
||||
}
|
||||
if tr.UpHi != 0 && tr.UpHi <= tr.UpLo {
|
||||
t.Errorf("%s / %s: uplink passband runs backwards", bird.Name, tr.Label)
|
||||
}
|
||||
// A linear transponder whose two passbands are different widths cannot
|
||||
// map one onto the other, and the split would drift across the pass.
|
||||
if tr.Linear() && (tr.DownHi-tr.DownLo) != (tr.UpHi-tr.UpLo) {
|
||||
t.Errorf("%s / %s: passbands are %d and %d Hz wide",
|
||||
bird.Name, tr.Label, tr.DownHi-tr.DownLo, tr.UpHi-tr.UpLo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByAlias(t *testing.T) {
|
||||
b := &Birds{}
|
||||
if err := b.parse(shippedBirds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Every spelling on the left is one an operator or a feed actually uses.
|
||||
for _, tc := range []struct{ query, want string }{
|
||||
{"AO-91", "AO-91"},
|
||||
{"RADFXSAT (FOX-1B)", "AO-91"},
|
||||
{"radfxsat", "AO-91"},
|
||||
{"ISS (ZARYA)", "ISS (ZARYA)"},
|
||||
{"ISS", "ISS (ZARYA)"},
|
||||
{"SAUDISAT 1C (SO-50)", "SO-50"},
|
||||
{"so 50", "SO-50"},
|
||||
{"QO-100", "QO-100"},
|
||||
{"ESHAIL-2", "QO-100"},
|
||||
{"Es'hail 2", "QO-100"},
|
||||
} {
|
||||
got, ok := b.Find(tc.query)
|
||||
if !ok {
|
||||
t.Errorf("%q was not found", tc.query)
|
||||
continue
|
||||
}
|
||||
if got.Name != tc.want {
|
||||
t.Errorf("%q found %q, wanted %q", tc.query, got.Name, tc.want)
|
||||
}
|
||||
}
|
||||
if _, ok := b.Find("NOAA 15"); ok {
|
||||
t.Error("a weather satellite should not carry an amateur frequency plan")
|
||||
}
|
||||
}
|
||||
|
||||
// The uplink maths is the part that matters on the air: a station worked at one
|
||||
// end of an inverting transponder has to be answered at the other.
|
||||
func TestUplinkFor(t *testing.T) {
|
||||
inv := Transponder{
|
||||
DownLo: 435800000, DownHi: 435900000,
|
||||
UpLo: 145900000, UpHi: 146000000,
|
||||
Inverting: true,
|
||||
}
|
||||
straight := Transponder{
|
||||
DownLo: 29400000, DownHi: 29500000,
|
||||
UpLo: 145850000, UpHi: 145950000,
|
||||
}
|
||||
fm := Transponder{DownLo: 436795000, UpLo: 145850000}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
tr Transponder
|
||||
down int64
|
||||
want int64
|
||||
}{
|
||||
{"inverting, bottom of the downlink", inv, 435800000, 146000000},
|
||||
{"inverting, top of the downlink", inv, 435900000, 145900000},
|
||||
{"inverting, 30 kHz up", inv, 435830000, 145970000},
|
||||
{"straight, bottom", straight, 29400000, 145850000},
|
||||
{"straight, 25 kHz up", straight, 29425000, 145875000},
|
||||
{"FM channel ignores the tuned downlink", fm, 436798000, 145850000},
|
||||
{"below the passband is clamped", inv, 435700000, 146000000},
|
||||
{"above the passband is clamped", inv, 436000000, 145900000},
|
||||
} {
|
||||
if got := tc.tr.UplinkFor(tc.down); got != tc.want {
|
||||
t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Receive-only: a beacon has nothing to answer on.
|
||||
if got := (Transponder{DownLo: 145800000}).UplinkFor(145800000); got != 0 {
|
||||
t.Errorf("a receive-only transponder gave an uplink of %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Whichever end the operator takes hold of, the pair has to agree.
|
||||
func TestDownlinkForRoundTrip(t *testing.T) {
|
||||
for _, tr := range []Transponder{
|
||||
{DownLo: 435800000, DownHi: 435900000, UpLo: 145900000, UpHi: 146000000, Inverting: true},
|
||||
{DownLo: 29400000, DownHi: 29500000, UpLo: 145850000, UpHi: 145950000},
|
||||
} {
|
||||
for _, down := range []int64{tr.DownLo, tr.Centre(), tr.DownHi} {
|
||||
if got := tr.DownlinkFor(tr.UplinkFor(down)); got != down {
|
||||
t.Errorf("inverting=%v: %d → uplink → %d", tr.Inverting, down, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b, err := LoadBirds(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shipped := b.Len()
|
||||
path := filepath.Join(dir, BirdsName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("the editable copy was not written: %v", err)
|
||||
}
|
||||
|
||||
// An operator's own list is what gets used from then on.
|
||||
mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]`
|
||||
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err = LoadBirds(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Len() != 1 {
|
||||
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
|
||||
}
|
||||
|
||||
// And a broken one falls back without destroying what they wrote.
|
||||
if err := os.WriteFile(path, []byte("[{oops"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err = LoadBirds(dir)
|
||||
if err == nil {
|
||||
t.Error("a broken list was accepted silently")
|
||||
}
|
||||
if b.Len() != shipped {
|
||||
t.Errorf("the shipped list did not take over: %d satellites", b.Len())
|
||||
}
|
||||
if data, _ := os.ReadFile(path); string(data) != "[{oops" {
|
||||
t.Error("the operator's broken file was overwritten")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user