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]>
356 lines
11 KiB
Go
356 lines
11 KiB
Go
package sat
|
|
|
|
import (
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// The frequency side of a satellite: what to listen on, what to transmit on,
|
|
// and how the two are tied together.
|
|
//
|
|
// The elements say where a bird is; this says what to do with the radio when it
|
|
// is there. They are separate on purpose — the elements change every few days
|
|
// and come from a feed, while a transponder plan changes when a satellite is
|
|
// commanded into another mode, which is a matter for the operator and AMSAT's
|
|
// published chart.
|
|
//
|
|
// The shipped list is a STARTING POINT, not an authority: satellites are
|
|
// switched between modes, transponders are turned off for a season, and new
|
|
// ones fly. It is 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.
|
|
|
|
//go:embed birds.json
|
|
var shippedBirds []byte
|
|
|
|
// BirdsName is the editable copy in the data directory.
|
|
const BirdsName = "satellites.json"
|
|
|
|
// Transponder is one usable path through a satellite.
|
|
type Transponder struct {
|
|
Label string `json:"label"`
|
|
Mode string `json:"mode"` // ADIF: FM, SSB, CW, DATA
|
|
|
|
// The downlink and uplink passbands, in Hz. A single frequency (an FM
|
|
// repeater, a beacon) sets only the "lo" of each side.
|
|
DownLo int64 `json:"down_lo"`
|
|
DownHi int64 `json:"down_hi,omitempty"`
|
|
UpLo int64 `json:"up_lo,omitempty"`
|
|
UpHi int64 `json:"up_hi,omitempty"`
|
|
|
|
// Inverting: the transponder turns the passband over, so tuning UP the
|
|
// downlink means going DOWN the uplink. Getting this backwards puts the
|
|
// operator's transmission at the far end of the passband from the station
|
|
// they can hear — which is the classic first evening on a linear bird.
|
|
Inverting bool `json:"inverting,omitempty"`
|
|
|
|
// CTCSS is the subaudible tone an FM uplink needs, in Hz. Zero = none.
|
|
CTCSS float64 `json:"ctcss,omitempty"`
|
|
}
|
|
|
|
// Linear reports a transponder with a passband rather than a single channel.
|
|
func (t Transponder) Linear() bool { return t.DownHi > t.DownLo && t.UpHi > t.UpLo }
|
|
|
|
// UplinkFor is where to transmit in order to be heard at downHz on the
|
|
// downlink.
|
|
//
|
|
// On a channel (FM) the answer is the uplink frequency, whatever the operator
|
|
// is tuned to. On a linear transponder it is a position in the passband — the
|
|
// same distance in from the edge, and from the OTHER edge when the transponder
|
|
// inverts.
|
|
func (t Transponder) UplinkFor(downHz int64) int64 {
|
|
if t.UpLo <= 0 {
|
|
return 0 // receive-only: a beacon, or a downlink we have no way to answer
|
|
}
|
|
if !t.Linear() {
|
|
return t.UpLo
|
|
}
|
|
if downHz < t.DownLo {
|
|
downHz = t.DownLo
|
|
}
|
|
if downHz > t.DownHi {
|
|
downHz = t.DownHi
|
|
}
|
|
offset := downHz - t.DownLo
|
|
if t.Inverting {
|
|
return t.UpHi - offset
|
|
}
|
|
return t.UpLo + offset
|
|
}
|
|
|
|
// DownlinkFor is the inverse: where a station transmitting at upHz comes out.
|
|
// It exists for the operator who tunes the uplink first — rarer, but the split
|
|
// has to be consistent whichever end they take hold of.
|
|
func (t Transponder) DownlinkFor(upHz int64) int64 {
|
|
if !t.Linear() {
|
|
return t.DownLo
|
|
}
|
|
if upHz < t.UpLo {
|
|
upHz = t.UpLo
|
|
}
|
|
if upHz > t.UpHi {
|
|
upHz = t.UpHi
|
|
}
|
|
if t.Inverting {
|
|
return t.DownLo + (t.UpHi - upHz)
|
|
}
|
|
return t.DownLo + (upHz - t.UpLo)
|
|
}
|
|
|
|
// Centre is the middle of the downlink passband — where to park when the
|
|
// operator picks a satellite and has not yet chosen a frequency in it.
|
|
func (t Transponder) Centre() int64 {
|
|
if !t.Linear() {
|
|
return t.DownLo
|
|
}
|
|
return t.DownLo + (t.DownHi-t.DownLo)/2
|
|
}
|
|
|
|
// Bird is one satellite's frequency plan.
|
|
type Bird struct {
|
|
Name string `json:"name"`
|
|
// NORAD is the catalog number, and the only exact way to find this
|
|
// satellite's elements: the feed, AMSAT and the operator all spell the NAME
|
|
// differently, while the number is carried inside the TLE itself. Aliases
|
|
// remain for the entries that predate it and for a hand-written plan.
|
|
NORAD int `json:"norad,omitempty"`
|
|
Aliases []string `json:"aliases,omitempty"`
|
|
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
|
|
// QO-100 is the reason the flag exists, and it changes what the whole
|
|
// tracking side does — there is nothing to predict and nothing to follow.
|
|
Geostationary bool `json:"geostationary,omitempty"`
|
|
Transponders []Transponder `json:"transponders"`
|
|
}
|
|
|
|
// Matches reports whether a name from an element feed is this satellite.
|
|
//
|
|
// The same rules Find uses, exposed for the other direction: the caller holds a
|
|
// bird and is scanning an element set spelled by somebody else.
|
|
func (b Bird) Matches(feedName string) bool {
|
|
cands := []string{feedName}
|
|
if i := strings.IndexByte(feedName, '('); i > 0 {
|
|
cands = append(cands, feedName[:i], strings.Trim(feedName[i:], "()"))
|
|
}
|
|
names := append([]string{b.Name}, b.Aliases...)
|
|
if i := strings.IndexByte(b.Name, '('); i > 0 {
|
|
names = append(names, b.Name[:i], strings.Trim(b.Name[i:], "()"))
|
|
}
|
|
for _, n := range names {
|
|
ln := loose(n)
|
|
if ln == "" {
|
|
continue
|
|
}
|
|
for _, c := range cands {
|
|
if ln == loose(c) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Birds is the frequency plan for every satellite the station knows.
|
|
type Birds struct {
|
|
mu sync.RWMutex
|
|
list []Bird
|
|
byKey map[string]int // name and aliases, loosely normalised → index in list
|
|
}
|
|
|
|
// loose is the matching form of a satellite name: upper case, letters and
|
|
// digits only.
|
|
//
|
|
// Feeds, AMSAT and operators all spell the same bird differently — "ES'HAIL 2",
|
|
// "ESHAIL-2", "Es'hail 2" — and none of them is wrong. Comparing the letters and
|
|
// digits alone is what lets the frequency plan meet the element set without a
|
|
// dozen aliases per satellite.
|
|
func loose(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToUpper(name) {
|
|
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// LoadBirds reads the plan from the data directory, writing the shipped copy
|
|
// there first if there is none.
|
|
//
|
|
// A file the operator has broken is NOT overwritten: it is reported and the
|
|
// shipped list is used for this session, so a stray comma costs a correction
|
|
// rather than the corrections of the last two years.
|
|
func LoadBirds(dir string) (*Birds, error) {
|
|
b := &Birds{}
|
|
path := filepath.Join(dir, BirdsName)
|
|
data, err := os.ReadFile(path)
|
|
switch {
|
|
case err == nil:
|
|
if perr := b.parse(data); perr != nil {
|
|
_ = b.parse(shippedBirds)
|
|
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
|
}
|
|
// New satellites reach an EXISTING station too.
|
|
//
|
|
// The operator's copy is written once, on the first run, and was then
|
|
// theirs for ever — which meant a release that added nine Tevel-2
|
|
// satellites reached nobody who had already opened the tab. Merging on
|
|
// each load fixes that without taking anything back: a satellite the
|
|
// operator already has is left exactly as it is, edits included, and
|
|
// only the ones they have never seen are added. Deleting a bird from the
|
|
// file therefore brings it back, which is the price of the trade — and
|
|
// the cheaper half of it, since an unwanted satellite is one row and a
|
|
// missing one is a pass nobody can work.
|
|
if n := b.addMissing(shippedBirds); n > 0 {
|
|
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
|
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
|
}
|
|
}
|
|
return b, nil
|
|
case os.IsNotExist(err):
|
|
if perr := b.parse(shippedBirds); perr != nil {
|
|
return nil, perr
|
|
}
|
|
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
|
_ = os.WriteFile(path, shippedBirds, 0o644)
|
|
}
|
|
return b, nil
|
|
default:
|
|
_ = b.parse(shippedBirds)
|
|
return b, err
|
|
}
|
|
}
|
|
|
|
func (b *Birds) parse(data []byte) error {
|
|
var list []Bird
|
|
if err := json.Unmarshal(data, &list); err != nil {
|
|
return err
|
|
}
|
|
byKey := make(map[string]int, len(list)*3)
|
|
put := func(name string, i int) {
|
|
if k := loose(name); k != "" {
|
|
// First writer wins: a satellite's own name must never be displaced by
|
|
// another bird's alias.
|
|
if _, seen := byKey[k]; !seen {
|
|
byKey[k] = i
|
|
}
|
|
}
|
|
}
|
|
for i, bird := range list {
|
|
put(bird.Name, i)
|
|
}
|
|
for i, bird := range list {
|
|
for _, a := range bird.Aliases {
|
|
put(a, i)
|
|
}
|
|
// "RADFXSAT (FOX-1B)" is one string in the feed and two names to an
|
|
// operator; index both halves so either spelling finds the bird.
|
|
if j := strings.IndexByte(bird.Name, '('); j > 0 {
|
|
put(bird.Name[:j], i)
|
|
put(strings.Trim(bird.Name[j:], "()"), i)
|
|
}
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.list, b.byKey = list, byKey
|
|
return nil
|
|
}
|
|
|
|
// Find looks a satellite up by name or alias.
|
|
//
|
|
// Celestrak says "RADFXSAT (FOX-1B)" where every operator says AO-91, so the
|
|
// bracketed halves are tried on their own before giving up — that is how most
|
|
// feed names differ from the name on the chart.
|
|
func (b *Birds) Find(name string) (Bird, bool) {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
try := func(s string) (Bird, bool) {
|
|
if i, ok := b.byKey[loose(s)]; ok {
|
|
return b.list[i], true
|
|
}
|
|
return Bird{}, false
|
|
}
|
|
if bird, ok := try(name); ok {
|
|
return bird, true
|
|
}
|
|
if i := strings.IndexByte(name, '('); i > 0 {
|
|
if bird, ok := try(name[:i]); ok {
|
|
return bird, true
|
|
}
|
|
if bird, ok := try(strings.Trim(name[i:], "()")); ok {
|
|
return bird, true
|
|
}
|
|
}
|
|
return Bird{}, false
|
|
}
|
|
|
|
// All lists the plan, in name order.
|
|
func (b *Birds) All() []Bird {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
out := append([]Bird(nil), b.list...)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// Len is how many satellites carry a frequency plan.
|
|
func (b *Birds) Len() int {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
return len(b.list)
|
|
}
|
|
|
|
// addMissing appends the satellites in `shipped` that this list does not already
|
|
// hold, and reports how many were added.
|
|
//
|
|
// "Already hold" is by catalog number first and by the loose name second, so an
|
|
// operator who renamed a bird, or who has it under the feed's spelling, does not
|
|
// get a second copy of it. Nothing existing is touched: their frequencies, their
|
|
// labels and their corrections all stand.
|
|
func (b *Birds) addMissing(shipped []byte) int {
|
|
var list []Bird
|
|
if err := json.Unmarshal(shipped, &list); err != nil {
|
|
return 0
|
|
}
|
|
b.mu.Lock()
|
|
have := make(map[int]bool, len(b.list))
|
|
for _, x := range b.list {
|
|
if x.NORAD != 0 {
|
|
have[x.NORAD] = true
|
|
}
|
|
}
|
|
added := 0
|
|
for _, cand := range list {
|
|
if cand.NORAD != 0 && have[cand.NORAD] {
|
|
continue
|
|
}
|
|
known := false
|
|
for _, name := range append([]string{cand.Name}, cand.Aliases...) {
|
|
if k := loose(name); k != "" {
|
|
if _, ok := b.byKey[k]; ok {
|
|
known = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if known {
|
|
continue
|
|
}
|
|
b.list = append(b.list, cand)
|
|
if cand.NORAD != 0 {
|
|
have[cand.NORAD] = true
|
|
}
|
|
if k := loose(cand.Name); k != "" {
|
|
b.byKey[k] = len(b.list) - 1
|
|
}
|
|
added++
|
|
}
|
|
b.mu.Unlock()
|
|
return added
|
|
}
|