Files
OpsLog/internal/sat/birds.go
T
rouggy 1009d06a4c feat(sat): the station side — elements, plan, passes and tuning
What internal/sat could not know: where the antenna is, which birds the
operator cares about, and where the files live.

Startup reads the cached elements and the frequency plan from disk and
nothing else — one file and a few hundred parses, so the tab is full the
moment it is opened, on a shack PC with no internet as much as on one
with. Fetching is the slow, optional half and never blocks a launch; it
happens on its own only when the set is stale and the operator asked for
it.

Elements pasted in by hand go in their own file. The feed cache is
replaced wholesale on every refresh, so a freshly launched satellite —
whose elements circulate on a mailing list days before any feed carries
it, which is exactly the week everybody wants to hear it — would
otherwise be wiped by the first automatic update.

The list joins both halves and shows what is missing on either side. A
bird with elements and no plan is one the operator can still track; a
bird with a plan and no elements is the visible symptom of an element set
that is too old. Dropping either turns a fixable configuration problem
into a satellite that "does not exist".

GetSatelliteTuning is the working answer, and everything that will later
drive a radio is built on top of it rather than beside it, so the display
and the rig can never disagree. It keeps the operator's frequency
nominal and applies Doppler only on the way out: on a linear pass the
station being answered stays put on the dial while both radios chase the
shift. A geostationary bird is corrected by nothing at all.
2026-09-07 10:51:16 +02:00

286 lines
8.5 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"`
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)
}
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)
}