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:
@@ -0,0 +1,444 @@
|
||||
// Command satgen refreshes internal/sat/birds.json from the public databases.
|
||||
//
|
||||
// A one-shot generator, run by hand, NOT part of the build — the same
|
||||
// arrangement as cmd/cntygen. Satellites are switched between modes and new
|
||||
// ones fly, and the shipped frequency plan should be re-cut every few releases
|
||||
// rather than typed from memory.
|
||||
//
|
||||
// go run ./cmd/satgen
|
||||
//
|
||||
// It reads three sources and joins them on the NORAD catalog number:
|
||||
//
|
||||
// - Celestrak's amateur group and PE0SAT's mirror, for WHICH satellites
|
||||
// OpsLog can get elements for. There is no point shipping a frequency plan
|
||||
// for a bird whose TLE never arrives.
|
||||
// - SatNOGS DB, for the transmitters. It is the maintained, machine-readable
|
||||
// transponder database; AMSAT's chart is authoritative but is a web page.
|
||||
//
|
||||
// It NEVER destroys a curated entry. The hand-written plans carry things
|
||||
// SatNOGS does not reliably hold — a CTCSS tone, a readable label, the QO-100
|
||||
// passband as operators actually describe it — so an existing bird is kept
|
||||
// verbatim and only has its NORAD number filled in. New satellites are appended.
|
||||
// Read the diff before committing it: this is a starting point for an operator,
|
||||
// and a wrong uplink is worse than a missing one.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/sat"
|
||||
)
|
||||
|
||||
const (
|
||||
birdsPath = "internal/sat/birds.json"
|
||||
satnogsTX = "https://db.satnogs.org/api/transmitters/?format=json"
|
||||
satnogsSats = "https://db.satnogs.org/api/satellites/?format=json"
|
||||
)
|
||||
|
||||
// satellite is the subset of a SatNOGS satellite record we use. Its whole
|
||||
// purpose is the decay date: a frequency plan for a spacecraft that burned up
|
||||
// two years ago is a row in the operator's list that will never do anything.
|
||||
type satellite struct {
|
||||
NORAD int `json:"norad_cat_id"`
|
||||
Name string `json:"name"`
|
||||
Names string `json:"names"` // other designations, comma or newline separated
|
||||
Status string `json:"status"`
|
||||
Decayed string `json:"decayed"`
|
||||
}
|
||||
|
||||
// tleFeeds are the element sources OpsLog itself reads (see internal/sat/tle.go).
|
||||
var tleFeeds = []string{
|
||||
"https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle",
|
||||
"http://tle.pe0sat.nl/kepler/amateur.txt",
|
||||
}
|
||||
|
||||
// transmitter is the subset of a SatNOGS DB record we use.
|
||||
type transmitter struct {
|
||||
Description string `json:"description"`
|
||||
Alive bool `json:"alive"`
|
||||
Type string `json:"type"` // Transmitter | Transponder | Transceiver
|
||||
UplinkLow int64 `json:"uplink_low"`
|
||||
UplinkHigh int64 `json:"uplink_high"`
|
||||
DownlinkLow int64 `json:"downlink_low"`
|
||||
DownlinkHigh int64 `json:"downlink_high"`
|
||||
Mode string `json:"mode"`
|
||||
Invert bool `json:"invert"`
|
||||
NORAD int `json:"norad_cat_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
feed, err := loadFeeds()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("elements: %d satellites across %d feeds\n", len(feed), len(tleFeeds))
|
||||
|
||||
txs, err := loadTransmitters()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("satnogs: %d transmitters\n", len(txs))
|
||||
|
||||
cat, err := loadSatellites()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("satnogs: %d catalogued satellites\n", len(cat))
|
||||
|
||||
birds, err := loadBirds()
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("existing plan: %d satellites\n", len(birds))
|
||||
|
||||
// 0. Drop what has come down. SatNOGS carries the re-entry date, so this is
|
||||
// a documented fact rather than a judgement about which of the missing
|
||||
// satellites are missing for good — the first-generation Tevel
|
||||
// constellation alone had left eight rows that could never do anything.
|
||||
kept := birds[:0]
|
||||
for _, b := range birds {
|
||||
if s, ok := decayed(b, cat); ok {
|
||||
fmt.Printf(" - %s re-entered %s — removed\n", b.Name, strings.TrimSuffix(s.Decayed, "T00:00:00Z"))
|
||||
continue
|
||||
}
|
||||
kept = append(kept, b)
|
||||
}
|
||||
birds = kept
|
||||
|
||||
// 1. Give every curated entry its catalog number, so the join stops
|
||||
// depending on how three different parties spell the same satellite.
|
||||
covered := map[int]bool{}
|
||||
for i := range birds {
|
||||
if birds[i].NORAD == 0 {
|
||||
if n, ok := noradFor(birds[i], feed); ok {
|
||||
birds[i].NORAD = n
|
||||
fmt.Printf(" + NORAD %5d for %s\n", n, birds[i].Name)
|
||||
} else {
|
||||
fmt.Printf(" ! no elements found for %s — left without a catalog number\n", birds[i].Name)
|
||||
}
|
||||
}
|
||||
if birds[i].NORAD != 0 {
|
||||
covered[birds[i].NORAD] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Append the satellites we can track and have a usable uplink for.
|
||||
byNORAD := map[int][]transmitter{}
|
||||
for _, t := range txs {
|
||||
if !usable(t) || feed[t.NORAD] == "" || covered[t.NORAD] {
|
||||
continue
|
||||
}
|
||||
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
||||
}
|
||||
added := 0
|
||||
for n, list := range byNORAD {
|
||||
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
||||
if alias := strings.TrimSpace(feed[n]); alias != "" && alias != b.Name {
|
||||
b.Aliases = []string{alias}
|
||||
}
|
||||
for _, t := range list {
|
||||
b.Transponders = append(b.Transponders, toTransponder(t))
|
||||
}
|
||||
sort.Slice(b.Transponders, func(i, j int) bool {
|
||||
return b.Transponders[i].DownLo < b.Transponders[j].DownLo
|
||||
})
|
||||
birds = append(birds, b)
|
||||
added++
|
||||
fmt.Printf(" NEW %5d %-24s %d transponder(s)\n", n, b.Name, len(b.Transponders))
|
||||
}
|
||||
|
||||
sort.SliceStable(birds, func(i, j int) bool { return birds[i].Name < birds[j].Name })
|
||||
out, err := json.MarshalIndent(birds, "", " ")
|
||||
if err != nil {
|
||||
die(err)
|
||||
}
|
||||
if err := os.WriteFile(birdsPath, append(out, '\n'), 0o644); err != nil {
|
||||
die(err)
|
||||
}
|
||||
fmt.Printf("\nwrote %s — %d satellites (%d new)\n", birdsPath, len(birds), added)
|
||||
}
|
||||
|
||||
// usable decides whether a SatNOGS transmitter is something an operator can
|
||||
// work through.
|
||||
//
|
||||
// The database holds every emission a satellite makes, and most of them are not
|
||||
// a contact: a telemetry beacon with a command uplink is listed exactly like an
|
||||
// FM repeater, and shipping the command channel as a transponder would invite
|
||||
// somebody to transmit on it. So both ends must exist, and anything that
|
||||
// describes itself as telemetry or control is refused unless it also calls
|
||||
// itself a repeater, a transponder or a digipeater.
|
||||
func usable(t transmitter) bool {
|
||||
if !t.Alive || t.Status != "active" {
|
||||
return false
|
||||
}
|
||||
if t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||
return false
|
||||
}
|
||||
d := strings.ToLower(t.Description)
|
||||
isWorkable := strings.Contains(d, "repeater") || strings.Contains(d, "transponder") ||
|
||||
strings.Contains(d, "digipeater") || strings.Contains(d, "aprs") ||
|
||||
strings.Contains(d, "voice") || strings.Contains(d, "sstv") || strings.Contains(d, "dstar")
|
||||
if isWorkable {
|
||||
return true
|
||||
}
|
||||
for _, bad := range []string{"telemetry", "command", "control", "dtmf", "beacon", "tlm"} {
|
||||
if strings.Contains(d, bad) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// An ANALOG emission with both ends is a contact by construction: nobody
|
||||
// puts an FM or SSB uplink on a satellite for housekeeping. This is what
|
||||
// catches the plainly-described repeaters — AO-27 says only "Mode V/U FM",
|
||||
// and rejecting it for not using the word "repeater" would have dropped one
|
||||
// of the best-known FM birds there is.
|
||||
if m := adifMode(t.Mode); m == "FM" || m == "SSB" || m == "CW" {
|
||||
return true
|
||||
}
|
||||
// A digital emission has to say what it is. A GMSK uplink is a command
|
||||
// channel far more often than it is a digipeater, and shipping the wrong one
|
||||
// invites an operator to transmit on a control frequency.
|
||||
return t.Type == "Transponder" || (t.UplinkHigh > t.UplinkLow && t.DownlinkHigh > t.DownlinkLow)
|
||||
}
|
||||
|
||||
// ctcssRe pulls a tone out of prose. SatNOGS has no field for it, and it is not
|
||||
// optional: an FM uplink without the right tone opens nothing at all.
|
||||
var ctcssRe = regexp.MustCompile(`(?i)(?:ctcss|pl)[^0-9]{0,4}(\d{2,3}(?:\.\d)?)|(\d{2,3}(?:\.\d)?)\s*(?:hz)?\s*(?:ctcss|pl)\b`)
|
||||
|
||||
func toTransponder(t transmitter) sat.Transponder {
|
||||
tp := sat.Transponder{
|
||||
Label: cleanLabel(t.Description),
|
||||
Mode: adifMode(t.Mode),
|
||||
DownLo: t.DownlinkLow,
|
||||
DownHi: t.DownlinkHigh,
|
||||
UpLo: t.UplinkLow,
|
||||
UpHi: t.UplinkHigh,
|
||||
Inverting: t.Invert,
|
||||
}
|
||||
// A "high" equal to the "low" is SatNOGS saying "a channel", not a one-hertz
|
||||
// passband; Transponder.Linear() must not be fooled into interpolating.
|
||||
if tp.DownHi <= tp.DownLo {
|
||||
tp.DownHi = 0
|
||||
}
|
||||
if tp.UpHi <= tp.UpLo {
|
||||
tp.UpHi = 0
|
||||
}
|
||||
// Inversion is a property of a PASSBAND. SatNOGS sets the flag on some FM
|
||||
// channels too, where it means nothing — the code ignores it there, but a
|
||||
// data file that says an FM repeater inverts is a data file that will
|
||||
// mislead the next person to read it.
|
||||
if tp.DownHi == 0 || tp.UpHi == 0 {
|
||||
tp.Inverting = false
|
||||
}
|
||||
if m := ctcssRe.FindStringSubmatch(t.Description); m != nil {
|
||||
v := m[1]
|
||||
if v == "" {
|
||||
v = m[2]
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 60 && f <= 260 {
|
||||
tp.CTCSS = f
|
||||
}
|
||||
}
|
||||
return tp
|
||||
}
|
||||
|
||||
// adifMode maps SatNOGS' modulation names onto the four modes a log knows.
|
||||
func adifMode(m string) string {
|
||||
switch u := strings.ToUpper(strings.TrimSpace(m)); {
|
||||
case strings.HasPrefix(u, "FM"), u == "SSTV", u == "DSTAR", u == "NFM":
|
||||
return "FM"
|
||||
case u == "USB", u == "LSB", u == "SSB":
|
||||
return "SSB"
|
||||
case u == "CW":
|
||||
return "CW"
|
||||
default:
|
||||
return "DATA"
|
||||
}
|
||||
}
|
||||
|
||||
func cleanLabel(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "Transponder"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// displayName prefers the OSCAR designation an operator says out loud.
|
||||
// "SAUDISAT 1C (SO-50)" is SO-50 to everybody except a catalog.
|
||||
func displayName(feedName string) string {
|
||||
s := strings.TrimSpace(feedName)
|
||||
if i := strings.IndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") {
|
||||
inner := strings.TrimSpace(s[i+1 : len(s)-1])
|
||||
if oscarRe.MatchString(inner) {
|
||||
return inner
|
||||
}
|
||||
}
|
||||
// "RS-44 & BREEZE-KM R/B" — the rocket body it flies with is not its name.
|
||||
if i := strings.Index(s, " & "); i > 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var oscarRe = regexp.MustCompile(`^[A-Z]{1,3}-\d{1,3}$`)
|
||||
|
||||
// noradFor finds a curated entry's catalog number by the name matching the
|
||||
// package already does.
|
||||
//
|
||||
// Deterministic on purpose. One satellite can hold TWO catalog entries — a
|
||||
// deployment catalogued before the objects were told apart, GreenCube being
|
||||
// 53106 and 53109 in the two feeds — and iterating the map picked a different
|
||||
// one each run, so the generated file changed for no reason and the diff was
|
||||
// unreadable. Candidates are therefore scored and tied on the lower number:
|
||||
// a feed name whose designation IS the bird's name ("GREENCUBE (IO-117)" for
|
||||
// IO-117) beats one that only matches through an alias.
|
||||
func noradFor(b sat.Bird, feed map[int]string) (int, bool) {
|
||||
nums := make([]int, 0, len(feed))
|
||||
for n := range feed {
|
||||
nums = append(nums, n)
|
||||
}
|
||||
sort.Ints(nums)
|
||||
|
||||
best, bestScore := 0, -1
|
||||
for _, n := range nums {
|
||||
name := feed[n]
|
||||
if !b.Matches(name) {
|
||||
continue
|
||||
}
|
||||
score := 0
|
||||
if strings.EqualFold(displayName(name), b.Name) {
|
||||
score = 2
|
||||
} else if strings.EqualFold(strings.TrimSpace(name), b.Name) {
|
||||
score = 1
|
||||
}
|
||||
if score > bestScore {
|
||||
best, bestScore = n, score
|
||||
}
|
||||
}
|
||||
return best, best != 0
|
||||
}
|
||||
|
||||
func loadFeeds() (map[int]string, error) {
|
||||
out := map[int]string{}
|
||||
for _, url := range tleFeeds {
|
||||
body, err := get(url)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", url, err)
|
||||
continue
|
||||
}
|
||||
lines := []string{}
|
||||
for _, l := range strings.Split(string(body), "\n") {
|
||||
if s := strings.TrimSpace(l); s != "" {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
}
|
||||
for i := 0; i+2 < len(lines); i += 3 {
|
||||
if !strings.HasPrefix(lines[i+1], "1 ") || len(lines[i+1]) < 7 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(lines[i+1][2:7]))
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
// First feed wins: Celestrak's spelling is the one the operator sees.
|
||||
if _, had := out[n]; !had {
|
||||
out[n] = lines[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no elements from any feed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decayed reports whether this bird's spacecraft has re-entered, matching on
|
||||
// the catalog number when we have one and on the designations SatNOGS lists
|
||||
// otherwise — "NAYIF-1" carries "EO-88" only in its alternative names.
|
||||
func decayed(b sat.Bird, cat []satellite) (satellite, bool) {
|
||||
for _, s := range cat {
|
||||
if s.Status != "re-entered" && s.Decayed == "" {
|
||||
continue
|
||||
}
|
||||
if b.NORAD != 0 {
|
||||
if s.NORAD == b.NORAD {
|
||||
return s, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
names := append(strings.FieldsFunc(s.Names, func(r rune) bool { return r == ',' || r == '\n' }), s.Name)
|
||||
for _, n := range names {
|
||||
if strings.TrimSpace(n) == "" {
|
||||
continue
|
||||
}
|
||||
if b.Matches(strings.TrimSpace(n)) {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return satellite{}, false
|
||||
}
|
||||
|
||||
func loadSatellites() ([]satellite, error) {
|
||||
body, err := get(satnogsSats)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []satellite
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("satnogs satellites: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadTransmitters() ([]transmitter, error) {
|
||||
body, err := get(satnogsTX)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []transmitter
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("satnogs: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadBirds() ([]sat.Bird, error) {
|
||||
b, err := os.ReadFile(birdsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []sat.Bird
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", birdsPath, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func get(url string) ([]byte, error) {
|
||||
c := &http.Client{Timeout: 90 * time.Second}
|
||||
resp, err := c.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
}
|
||||
|
||||
func die(err error) {
|
||||
fmt.Fprintln(os.Stderr, "satgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user