Files
OpsLog/internal/sat/birds.go
T
rouggyandClaude Opus 5 580e5782f8 fix(sat): a frequency we shipped wrong can now be mended
"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]>
2026-09-10 10:41:16 +02:00

500 lines
16 KiB
Go

package sat
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"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 AND corrections reach an existing station.
//
// The operator's copy is written on the first run and was then theirs
// for ever, which broke both ways: a release that added nine Tevel-2
// satellites reached nobody who had opened the tab, and a frequency we
// had shipped WRONG could never be mended — LilacSat-2 went out with an
// APRS digipeater and no FM transponder, and the wrong data had become
// the operator's own file.
//
// So the merge adds what is missing, replaces what they never touched,
// and leaves alone what they edited. See mergeShipped for how the three
// are told apart.
added, updated, kept, replaced := b.mergeShipped(dir)
if added > 0 || updated > 0 {
// A one-time copy before the first run that can overwrite an entry
// we have no baseline for. Cheap insurance on a file an operator may
// have spent an evening correcting.
if len(replaced) > 0 {
if err := os.WriteFile(path+".bak", data, 0o644); err == nil {
log.Printf("sat: %s copied to %s.bak before the plan was brought up to date", BirdsName, BirdsName)
}
}
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
_ = os.WriteFile(path, append(out, '\n'), 0o644)
}
log.Printf("sat: frequency plan — %d satellites added, %d brought up to date", added, updated)
}
if len(replaced) > 0 {
log.Printf("sat: %s taken from the shipped plan (no record of what this station was given). "+
"If one of those was your own correction, it is in %s.bak", strings.Join(replaced, ", "), BirdsName)
}
if len(kept) > 0 {
// Named, not silent: an operator who corrected a frequency should be
// able to see that OpsLog noticed and stood down.
log.Printf("sat: your own edits kept for %s — delete them from %s to take the shipped plan instead",
strings.Join(kept, ", "), BirdsName)
}
writeBaseline(dir)
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)
writeBaseline(dir)
}
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)
}
// BaselineName records the shipped plan as it was last handed to this station.
//
// It exists so a CORRECTION can reach an operator who already has the file.
// Without it the merge could only add satellites, never mend one: LilacSat-2
// shipped with an APRS digipeater and no FM transponder, and every station that
// had already opened the satellite tab was stuck with it for ever — the wrong
// frequency was the operator's file now, and their file was sacred.
const BaselineName = "satellites.shipped.json"
// mergeShipped brings the shipped plan into the operator's list.
//
// Three cases, and the middle one is the point:
//
// - A satellite they do not have is ADDED. That is how new birds arrive.
// - A satellite they have, UNCHANGED from the plan they were given, is
// REPLACED by the current one. They never edited it, so it is not theirs to
// keep — it is our data, and ours was wrong.
// - A satellite they have EDITED is left exactly as it is, and said so 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 the baseline, so the comparison is with the
// plan THEY were given rather than with whatever ships today. Their edits
// therefore survive every future release, not just the next one.
func (b *Birds) mergeShipped(dir string) (added, updated int, kept, replaced []string) {
var list []Bird
if err := json.Unmarshal(shippedBirds, &list); err != nil {
return 0, 0, nil, nil
}
baseline := readBaseline(dir)
b.mu.Lock()
byNORAD := map[int]int{} // catalog number → index in b.list
byName := map[string]int{}
for i, x := range b.list {
if x.NORAD != 0 {
byNORAD[x.NORAD] = i
}
for _, n := range append([]string{x.Name}, x.Aliases...) {
if k := loose(n); k != "" {
if _, seen := byName[k]; !seen {
byName[k] = i
}
}
}
}
find := func(c Bird) int {
if c.NORAD != 0 {
if i, ok := byNORAD[c.NORAD]; ok {
return i
}
}
for _, n := range append([]string{c.Name}, c.Aliases...) {
if i, ok := byName[loose(n)]; ok {
return i
}
}
return -1
}
for _, cand := range list {
i := find(cand)
if i < 0 {
b.list = append(b.list, cand)
added++
continue
}
if sameBird(b.list[i], cand) {
continue // already current
}
was, hadBaseline := baseline[birdKey(cand)]
switch {
case !hadBaseline:
// FIRST run after baselines existed, and there is no record of what
// this station was given — so an edit of theirs and a mistake of
// ours are indistinguishable here.
//
// The shipped plan wins, ONCE, and the whole file is backed up
// first. Standing down instead would have been the safe-looking
// choice and the wrong one: the baseline written at the end of this
// run would then record their entry as "edited" and freeze a
// frequency we know to be wrong for the life of the install. A
// backup and a log line are recoverable; that is not.
replaced = append(replaced, b.list[i].Name)
b.list[i] = cand
updated++
case sameBird(b.list[i], was):
b.list[i] = cand
updated++
default:
kept = append(kept, b.list[i].Name)
}
}
b.reindexLocked()
b.mu.Unlock()
return added, updated, kept, replaced
}
// birdKey identifies a satellite across versions: the catalog number when there
// is one, the loose name otherwise.
func birdKey(x Bird) string {
if x.NORAD != 0 {
return "n:" + strconv.Itoa(x.NORAD)
}
return "s:" + loose(x.Name)
}
// sameBird compares two plans for one satellite by VALUE — the frequencies, the
// modes, the tone, the labels. Field by field through JSON rather than one
// comparison per field, so a transponder field added later cannot silently drop
// out of the test and start reporting equal plans as different.
func sameBird(a, c Bird) bool {
ja, ea := json.Marshal(a)
jc, ec := json.Marshal(c)
if ea != nil || ec != nil {
return false
}
return bytes.Equal(ja, jc)
}
// readBaseline loads the shipped plan this station was last given.
func readBaseline(dir string) map[string]Bird {
out := map[string]Bird{}
raw, err := os.ReadFile(filepath.Join(dir, BaselineName))
if err != nil {
return out
}
var list []Bird
if json.Unmarshal(raw, &list) != nil {
return out
}
for _, x := range list {
out[birdKey(x)] = x
}
return out
}
// writeBaseline records what was shipped, so the NEXT release can tell an
// operator's correction from one of ours.
func writeBaseline(dir string) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(filepath.Join(dir, BaselineName), shippedBirds, 0o644)
}
// reindexLocked rebuilds the name lookup after the list has changed.
func (b *Birds) reindexLocked() {
byKey := make(map[string]int, len(b.list)*3)
put := func(name string, i int) {
if k := loose(name); k != "" {
if _, seen := byKey[k]; !seen {
byKey[k] = i
}
}
}
for i, bird := range b.list {
put(bird.Name, i)
}
for i, bird := range b.list {
for _, a := range bird.Aliases {
put(a, i)
}
}
b.byKey = byKey
}