"la lilacsat je le vois en DATA ???!" — and the LO-90 fix could not have reached
him. The satellite file is copied out on the first run and was the operator's
from then on, so the merge could only ADD birds, never repair one. LilacSat-2
went out with an APRS digipeater and no FM transponder, and that mistake had
become his data, permanently.
The merge now distinguishes three cases, and the middle one is the whole point:
- a satellite he does not have is added;
- one he has, UNCHANGED from the plan he was given, is replaced — he never
edited it, so it is not his to keep: it is our data and ours was wrong;
- one he EDITED is left exactly alone, and named in the log. A frequency
somebody corrected by hand outranks anything shipped; they were on the air
and we were not.
"Unchanged" is decided against a baseline — satellites.shipped.json, the plan
this station was last handed — so the comparison is with what THEY were given
rather than with whatever ships today. Their edits survive every future release,
not just the next one.
The first run after this has no baseline, and there an edit of theirs and a
mistake of ours are indistinguishable. The shipped plan wins, once, with the
whole file copied to satellites.json.bak first and every replacement named. The
safe-looking alternative was the wrong one: standing down would have written a
baseline recording their entry as "edited" and frozen a known-wrong frequency
for the life of the install.
Verified against his own file: LILACSAT-2 becomes LO-90 with the FM transponder
first, and the twelve curated entries that were missing their catalog numbers
get them — which also closes the NORAD gap left open when the exact join went in.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
414 lines
13 KiB
Go
414 lines
13 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The three cases the merge has to tell apart. Getting the middle one wrong is
|
|
// how LilacSat-2 shipped with no FM transponder and could never be mended.
|
|
func TestMergeShippedRespectsEditsAndFixesOurs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// First run: the shipped plan and its baseline are written.
|
|
if _, err := LoadBirds(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, BaselineName)); err != nil {
|
|
t.Fatalf("no baseline was recorded: %v", err)
|
|
}
|
|
path := filepath.Join(dir, BirdsName)
|
|
|
|
// The operator corrects SO-50's tone and adds a satellite of their own.
|
|
var list []Bird
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(raw, &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var untouched Bird
|
|
for i := range list {
|
|
if list[i].Name == "SO-50" {
|
|
list[i].Transponders[0].CTCSS = 74.4
|
|
}
|
|
if list[i].Name == "AO-7" {
|
|
untouched = list[i] // left exactly as shipped
|
|
}
|
|
}
|
|
list = append(list, Bird{Name: "MY-SAT", Transponders: []Transponder{{Label: "mine", Mode: "FM", DownLo: 145000000, UpLo: 435000000}}})
|
|
out, _ := json.MarshalIndent(list, "", " ")
|
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Load again. Nothing shipped has changed, so nothing should move.
|
|
b, err := LoadBirds(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, ok := b.Find("SO-50")
|
|
if !ok || got.Transponders[0].CTCSS != 74.4 {
|
|
t.Errorf("the operator's tone was lost: %+v", got.Transponders)
|
|
}
|
|
if _, ok := b.Find("MY-SAT"); !ok {
|
|
t.Error("the operator's own satellite was dropped")
|
|
}
|
|
if a, ok := b.Find("AO-7"); !ok || !sameBird(a, untouched) {
|
|
t.Error("an untouched satellite was altered for no reason")
|
|
}
|
|
}
|
|
|
|
// An entry the operator never touched is REPLACED when the shipped plan
|
|
// changes. That is the whole point: our data, and ours was wrong.
|
|
func TestMergeShippedUpdatesWhatWasNeverEdited(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := LoadBirds(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, BirdsName)
|
|
|
|
// Pretend an older release shipped SO-50 with a wrong downlink, and that the
|
|
// operator simply took it: their file AND the baseline both hold the wrong
|
|
// value, which is exactly what "never edited" looks like.
|
|
rewrite := func(p string, mutate func(*Bird)) {
|
|
raw, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var l []Bird
|
|
if err := json.Unmarshal(raw, &l); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := range l {
|
|
if l[i].Name == "SO-50" {
|
|
mutate(&l[i])
|
|
}
|
|
}
|
|
out, _ := json.MarshalIndent(l, "", " ")
|
|
if err := os.WriteFile(p, out, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
wrong := func(x *Bird) { x.Transponders[0].DownLo = 1 }
|
|
rewrite(path, wrong)
|
|
rewrite(filepath.Join(dir, BaselineName), wrong)
|
|
|
|
b, err := LoadBirds(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, ok := b.Find("SO-50")
|
|
if !ok {
|
|
t.Fatal("SO-50 vanished")
|
|
}
|
|
if got.Transponders[0].DownLo == 1 {
|
|
t.Error("a value the operator never edited was not brought up to date — a shipped mistake is unfixable")
|
|
}
|
|
}
|
|
|
|
// With NO baseline — the first run after baselines existed — the shipped plan
|
|
// wins and the file is backed up. Standing down would freeze a known-wrong
|
|
// frequency for the life of the install.
|
|
func TestMergeShippedWithNoBaselineTakesShippedAndBacksUp(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := LoadBirds(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
path := filepath.Join(dir, BirdsName)
|
|
if err := os.Remove(filepath.Join(dir, BaselineName)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A wrong value, with nothing to say whether it is ours or theirs.
|
|
raw, _ := os.ReadFile(path)
|
|
var l []Bird
|
|
if err := json.Unmarshal(raw, &l); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := range l {
|
|
if l[i].Name == "SO-50" {
|
|
l[i].Transponders[0].DownLo = 1
|
|
}
|
|
}
|
|
out, _ := json.MarshalIndent(l, "", " ")
|
|
if err := os.WriteFile(path, out, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
b, err := LoadBirds(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := b.Find("SO-50")
|
|
if got.Transponders[0].DownLo == 1 {
|
|
t.Error("the shipped plan did not take over, so the wrong value is now frozen for ever")
|
|
}
|
|
if _, err := os.Stat(path + ".bak"); err != nil {
|
|
t.Errorf("no backup was written before overwriting: %v", err)
|
|
}
|
|
}
|