feat(sat): generate the frequency plan, and join on the catalog number

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]>
This commit is contained in:
2026-09-09 11:20:22 +02:00
co-authored by Claude Opus 5
parent fcf00e04f4
commit 86fd03fd6b
7 changed files with 1199 additions and 311 deletions
+86 -4
View File
@@ -1,6 +1,7 @@
package sat
import (
"encoding/json"
"os"
"path/filepath"
"testing"
@@ -158,8 +159,19 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
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}]}]`
// 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)
}
@@ -167,8 +179,23 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if b.Len() != 1 {
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
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.
@@ -186,3 +213,58 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
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)
}
}
}
}