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]>
This commit is contained in:
+187
-43
@@ -1,12 +1,15 @@
|
||||
package sat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -195,22 +198,44 @@ func LoadBirds(dir string) (*Birds, error) {
|
||||
_ = 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.
|
||||
// New satellites AND corrections reach an existing station.
|
||||
//
|
||||
// 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 {
|
||||
// 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 {
|
||||
@@ -218,6 +243,7 @@ func LoadBirds(dir string) (*Birds, error) {
|
||||
}
|
||||
if werr := os.MkdirAll(dir, 0o755); werr == nil {
|
||||
_ = os.WriteFile(path, shippedBirds, 0o644)
|
||||
writeBaseline(dir)
|
||||
}
|
||||
return b, nil
|
||||
default:
|
||||
@@ -305,51 +331,169 @@ func (b *Birds) Len() int {
|
||||
return len(b.list)
|
||||
}
|
||||
|
||||
// addMissing appends the satellites in `shipped` that this list does not already
|
||||
// hold, and reports how many were added.
|
||||
// BaselineName records the shipped plan as it was last handed to this station.
|
||||
//
|
||||
// "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 {
|
||||
// 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(shipped, &list); err != nil {
|
||||
return 0
|
||||
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||
return 0, 0, nil, nil
|
||||
}
|
||||
baseline := readBaseline(dir)
|
||||
|
||||
b.mu.Lock()
|
||||
have := make(map[int]bool, len(b.list))
|
||||
for _, x := range b.list {
|
||||
byNORAD := map[int]int{} // catalog number → index in b.list
|
||||
byName := map[string]int{}
|
||||
for i, x := range b.list {
|
||||
if x.NORAD != 0 {
|
||||
have[x.NORAD] = true
|
||||
byNORAD[x.NORAD] = i
|
||||
}
|
||||
}
|
||||
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
|
||||
for _, n := range append([]string{x.Name}, x.Aliases...) {
|
||||
if k := loose(n); k != "" {
|
||||
if _, seen := byName[k]; !seen {
|
||||
byName[k] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if known {
|
||||
}
|
||||
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
|
||||
}
|
||||
b.list = append(b.list, cand)
|
||||
if cand.NORAD != 0 {
|
||||
have[cand.NORAD] = true
|
||||
if sameBird(b.list[i], cand) {
|
||||
continue // already current
|
||||
}
|
||||
if k := loose(cand.Name); k != "" {
|
||||
b.byKey[k] = len(b.list) - 1
|
||||
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)
|
||||
}
|
||||
added++
|
||||
}
|
||||
b.reindexLocked()
|
||||
b.mu.Unlock()
|
||||
return added
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user