25 satellites, typed by hand and never revisited. Eight of them were the
first-generation Tevel constellation, which re-entered in 2024; four more had
come down too; and the nine Tevel-2 satellites that replaced them, the Chinese
space station, AO-27, AO-123 and twenty others were simply absent. So: 44
satellites now, and a generator instead of a memory.
cmd/satgen joins three public sources on the NORAD catalog number — Celestrak's
amateur group and PE0SAT's mirror for which birds OpsLog can actually get
elements for, and SatNOGS DB for the transmitters. It is a one-shot tool, run by
hand, the same arrangement as cmd/cntygen, and it is deliberately conservative:
- It never destroys a curated entry. The hand-written plans hold things
SatNOGS does not reliably carry — a CTCSS tone, the QO-100 passband as
operators describe it — so an existing bird keeps its data and only gains
its catalog number.
- It prunes on the re-entry date, which is a fact SatNOGS publishes rather
than a judgement about which of the missing satellites are missing for good.
- It refuses a digital uplink that does not say what it is. A GMSK uplink is a
command channel far more often than a digipeater, and shipping the wrong one
invites somebody to transmit on a control frequency. An analog uplink with
both ends is a contact by construction, which is what catches the repeaters
that describe themselves only as "Mode V/U FM".
- Its output is deterministic. One satellite can hold two catalog entries —
GreenCube is 53106 in one feed and 53109 in the other — and iterating a map
picked a different one each run.
A bird now carries its NORAD number, and that is how its elements are found.
Names were the only join before, and they are written differently by every party
involved: "TIANYAN 01" and "TO-108" are one satellite that had never once met,
so TO-108 tracked nothing at all.
And the plan now reaches a station that has already run OpsLog. The editable
copy was written on the first launch and was the operator's list for ever after,
so a release adding nine satellites reached nobody who had opened the tab. It is
merged on each load instead: a satellite they already have is untouched, edits
and corrections included, and only the ones they have never seen are added.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
271 lines
9.1 KiB
Go
271 lines
9.1 KiB
Go
package sat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// Matches is the other direction: a bird in hand, scanning a feed's names.
|
|
func TestBirdMatches(t *testing.T) {
|
|
b := Bird{Name: "AO-91", Aliases: []string{"RADFXSAT", "FOX-1B"}}
|
|
for _, feed := range []string{"AO-91", "RADFXSAT (FOX-1B)", "radfxsat", "FOX 1B"} {
|
|
if !b.Matches(feed) {
|
|
t.Errorf("%q was not recognised as AO-91", feed)
|
|
}
|
|
}
|
|
for _, feed := range []string{"AO-92", "NOAA 15", "FOX-1A"} {
|
|
if b.Matches(feed) {
|
|
t.Errorf("%q was wrongly taken for AO-91", feed)
|
|
}
|
|
}
|
|
// A bracketed catalogue name matched from the other side.
|
|
iss := Bird{Name: "ISS (ZARYA)"}
|
|
if !iss.Matches("ISS") || !iss.Matches("ZARYA") {
|
|
t.Error("the ISS was not recognised by either half of its catalogue name")
|
|
}
|
|
}
|
|
|
|
// 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 kept, AND the shipped satellites they have never
|
|
// seen are added to it.
|
|
//
|
|
// The merge is what carries a new satellite to a station that has already
|
|
// run OpsLog once: without it, the copy written on the very first launch was
|
|
// the operator's list for ever, and a release adding nine Tevel-2 birds
|
|
// reached nobody. What it must never do is take something back — so the
|
|
// operator's own satellite, and their correction to a shipped one, both have
|
|
// to survive it.
|
|
mine := `[
|
|
{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]},
|
|
{"name":"SO-50","norad":27607,"transponders":[{"label":"corrected","mode":"FM","down_lo":436796000,"up_lo":145850000,"ctcss":74.4}]}
|
|
]`
|
|
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() < shipped {
|
|
t.Fatalf("the shipped satellites were not merged in: %d, expected at least %d", b.Len(), shipped)
|
|
}
|
|
if _, ok := b.Find("MY-SAT"); !ok {
|
|
t.Error("the operator's own satellite was dropped by the merge")
|
|
}
|
|
// Their correction stands: same tone, same frequency, not the shipped one.
|
|
got, ok := b.Find("SO-50")
|
|
if !ok {
|
|
t.Fatal("SO-50 vanished")
|
|
}
|
|
if len(got.Transponders) != 1 || got.Transponders[0].CTCSS != 74.4 || got.Transponders[0].DownLo != 436796000 {
|
|
t.Errorf("the operator's correction to SO-50 was overwritten: %+v", got.Transponders)
|
|
}
|
|
// And a shipped satellite they had never seen is now there.
|
|
if _, ok := b.Find("AO-7"); !ok {
|
|
t.Error("a shipped satellite was not added to the operator's list")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// The shipped plan is generated (cmd/satgen) from public databases, so it is
|
|
// worth its own guard, on top of TestShippedBirds above: a bad regeneration
|
|
// should fail here rather than mistune an antenna on somebody first pass.
|
|
func TestGeneratedBirdsAreSane(t *testing.T) {
|
|
var list []Bird
|
|
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
|
t.Fatalf("birds.json does not parse: %v", err)
|
|
}
|
|
// The generator joins three feeds. If one of them answered with nothing, the
|
|
// output silently shrinks, and this is where that shows up.
|
|
if len(list) < 30 {
|
|
t.Errorf("only %d satellites shipped — the generator probably ran against an empty feed", len(list))
|
|
}
|
|
seenNORAD := map[int]string{}
|
|
seenName := map[string]bool{}
|
|
for _, b := range list {
|
|
if b.Name == "" {
|
|
t.Error("a satellite with no name")
|
|
}
|
|
if seenName[loose(b.Name)] {
|
|
t.Errorf("%s appears twice", b.Name)
|
|
}
|
|
seenName[loose(b.Name)] = true
|
|
// Two entries for one catalog number is one satellite the operator can
|
|
// pick twice, with two different sets of frequencies.
|
|
if b.NORAD != 0 {
|
|
if other, dup := seenNORAD[b.NORAD]; dup {
|
|
t.Errorf("NORAD %d is both %s and %s", b.NORAD, other, b.Name)
|
|
}
|
|
seenNORAD[b.NORAD] = b.Name
|
|
}
|
|
for _, tp := range b.Transponders {
|
|
// An amateur satellite works between 15 m and 24 GHz. Anything outside
|
|
// that is a units mistake, and a units mistake is how a rig ends up
|
|
// commanded somewhere it cannot go.
|
|
for _, hz := range []int64{tp.DownLo, tp.DownHi, tp.UpLo, tp.UpHi} {
|
|
if hz != 0 && (hz < 21_000_000 || hz > 24_000_000_000) {
|
|
t.Errorf("%s: %d Hz is not an amateur satellite frequency", b.Name, hz)
|
|
}
|
|
}
|
|
// Inversion only means something across a passband. The code ignores
|
|
// the flag on a channel, but a file that claims an FM repeater inverts
|
|
// will mislead whoever reads it next.
|
|
if tp.Inverting && !tp.Linear() {
|
|
t.Errorf("%s: %q is a channel and cannot invert", b.Name, tp.Label)
|
|
}
|
|
switch tp.Mode {
|
|
case "FM", "SSB", "CW", "DATA":
|
|
default:
|
|
t.Errorf("%s: %q is not an ADIF mode the log can store", b.Name, tp.Mode)
|
|
}
|
|
}
|
|
}
|
|
}
|