Files
OpsLog/cmd/satdiag/main.go
T
rouggyandClaude Opus 5 3ac6f7e49c fix(sat): the Flex slices get their antenna, their sideband and their tone
Three things the tracker was leaving to chance on a FlexRadio, all reported from
a real pass.

ANTENNAS. Settings ▸ FlexRadio holds a per-band RX/TX antenna map, and it was
applied in exactly one place: the entry form, on a band change, to the active
slice. A pass never goes through that path — the tracker arms two slices itself.
So both were left on whatever the radio last used, and a station with
transverters (XVTA on 2 m, XVTB on 70 cm) heard nothing at all, having
configured precisely the thing being ignored. The two slices are on two
different bands, so they cannot share one setting: the downlink takes the
receive antenna for ITS band, the uplink the transmit antenna for its. Per
slice, not through sendSlice, which addresses whichever slice is active — during
a pass that is the downlink, so the uplink would never have been set.

SIDEBAND. satMode forced USB above 30 MHz on both sides. An inverting
transponder turns the passband over, so lower sideband up comes back as upper
sideband down: FO-29, RS-44 and AO-73 were being worked with the operator's own
audio going through upside down. The tracker now decides both sidebands from the
transponder's inverting flag and passes them separately; a bare "SSB" still
means USB, so nothing else changes.

CTCSS. Nothing set it, on any bird. The frequency plan has carried the tone all
along — 67.0 on SO-50 and AO-91, 141.3 on PO-101 — and an FM repeater does not
answer without it, which is indistinguishable from a satellite that is not
there. It goes on the uplink slice, value before mode so the radio cannot
transmit the previous tone in the gap between two commands.

Written against the SmartSDR slice API and UNTESTED on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 20:20:41 +02:00

183 lines
5.4 KiB
Go

// Command satdiag answers "is this pass real, and is that Doppler right?" from
// a station's own cached elements, without launching OpsLog.
//
// go run ./cmd/satdiag <data dir> <locator> <satellite>
//
// It prints which element set the satellite resolved to and how old it is, the
// look angle now, the range rate BOTH as the propagator reports it and as the
// range actually changes, the Doppler each transponder would be given, and the
// next passes. It exists because a wrong Doppler and a wrong satellite look the
// same from the front — an operator saying "the frequency moves enormously" —
// and the two are told apart by these numbers in a second.
//
// It found the range rate the SGP4 library reports being wrong by a factor of
// 250 and of the wrong sign. Not part of the build.
package main
import (
"fmt"
"os"
"strings"
"time"
"hamlog/internal/sat"
)
func main() {
dir := os.Args[1]
grid := os.Args[2]
name := os.Args[3]
f := sat.NewFetcher(dir)
els, at, err := f.LoadCache()
if err != nil {
fmt.Println("cache:", err)
os.Exit(1)
}
store := sat.NewStore()
store.Replace(els, at)
fmt.Printf("elements: %d, fetched %s (%s ago)\n\n", len(els), at.Format(time.RFC3339), time.Since(at).Round(time.Minute))
birds, err := sat.LoadBirds(dir)
if err != nil {
fmt.Println("birds:", err)
}
b, ok := birds.Find(name)
if !ok {
fmt.Println("no frequency plan for", name)
os.Exit(1)
}
// The same resolution the app does.
var el sat.Element
found := false
if e, ok := store.GetNORAD(b.NORAD); ok {
el, found = e, true
fmt.Printf("elements found BY NORAD %d → %q\n", b.NORAD, e.Name)
} else if e, ok := store.Get(b.Name); ok {
el, found = e, true
fmt.Printf("elements found by name → %q (NORAD %d)\n", e.Name, e.NORAD)
} else {
for _, a := range b.Aliases {
if e, ok := store.Get(a); ok {
el, found = e, true
fmt.Printf("elements found by alias %q → %q (NORAD %d)\n", a, e.Name, e.NORAD)
break
}
}
}
if !found {
// Last resort, exactly as satElement does: scan every element name and
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
// that resolves perfectly well in the app look unresolvable here.
for _, n := range store.Names() {
if b.Matches(n) {
if e, ok := store.Get(n); ok {
el, found = e, true
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
break
}
}
}
}
if !found {
fmt.Println("NO ELEMENTS")
os.Exit(1)
}
fmt.Printf("epoch: %s (%s old)\n", el.Epoch.Format(time.RFC3339), time.Since(el.Epoch).Round(time.Hour))
fmt.Println("line1:", el.Line1)
lat, lon, okGrid := gridToLatLon(grid)
if !okGrid {
fmt.Println("bad locator:", grid)
os.Exit(1)
}
obs := sat.Observer{Lat: lat, Lon: lon}
fmt.Printf("observer: %s → %.4f, %.4f\n\n", grid, obs.Lat, obs.Lon)
now := time.Now().UTC()
p, err := el.Track(obs, now)
if err != nil {
fmt.Println("track:", err)
os.Exit(1)
}
fmt.Printf("NOW %s : az %.1f el %.1f range %.0f km\n", now.Format("15:04:05"), p.Az, p.El, p.RangeKm)
fmt.Printf(" range rate REPORTED by the library : %+10.3f km/s\n", p.RangeRate)
fmt.Printf(" range rate MEASURED (d range / dt) : %+10.3f km/s\n", numericRate(el, obs, now))
for _, tp := range b.Transponders {
sh := sat.Doppler(p, tp.DownLo, tp.UpLo)
fmt.Printf(" %-28s down %d → %d (%+d Hz) up %d → %d (%+d Hz)\n",
tp.Label, tp.DownLo, sh.DownHz, sh.DownHz-tp.DownLo, tp.UpLo, sh.UpHz, sh.UpHz-tp.UpLo)
}
fmt.Println("\nnext passes (min el 0):")
passes, err := store.Passes(el.Name, obs, now, now.Add(12*time.Hour), 0)
if err != nil {
fmt.Println("passes:", err)
}
for i, ps := range passes {
if i >= 8 {
break
}
fmt.Printf(" %s → %s max %.1f° az %.0f→%.0f\n",
ps.AOS.Format("15:04:05"), ps.LOS.Format("15:04:05"), ps.MaxEl, ps.AOSAz, ps.LOSAz)
}
// The extremes of the Doppler across the next pass, which is the honest
// answer to "does it move that much".
if len(passes) > 0 {
ps := passes[0]
var lo, hi int64
for tt := ps.AOS; tt.Before(ps.LOS); tt = tt.Add(10 * time.Second) {
q, err := el.Track(obs, tt)
if err != nil {
continue
}
d := sat.Doppler(q, b.Transponders[0].DownLo, 0).DownHz - b.Transponders[0].DownLo
if d < lo {
lo = d
}
if d > hi {
hi = d
}
}
fmt.Printf("\ndownlink Doppler across that pass: %+d Hz … %+d Hz (span %d Hz)\n", lo, hi, hi-lo)
}
}
// gridToLatLon is the six-character Maidenhead centre.
func gridToLatLon(g string) (float64, float64, bool) {
g = strings.ToUpper(strings.TrimSpace(g))
if len(g) < 4 {
return 0, 0, false
}
lon := float64(g[0]-'A')*20 - 180
lat := float64(g[1]-'A')*10 - 90
lon += float64(g[2]-'0') * 2
lat += float64(g[3]-'0') * 1
if len(g) >= 6 {
lon += float64(g[4]-'A') * (2.0 / 24)
lat += float64(g[5]-'A') * (1.0 / 24)
lon += (2.0 / 24) / 2
lat += (1.0 / 24) / 2
} else {
lon += 1
lat += 0.5
}
return lat, lon, true
}
// numericRate is the range rate measured rather than reported: the distance a
// second later minus the distance a second earlier, over two seconds. It cannot
// disagree with physics, so it is the reference the library's own figure is
// checked against.
func numericRate(el sat.Element, obs sat.Observer, at time.Time) float64 {
a, e1 := el.Track(obs, at.Add(-time.Second))
b, e2 := el.Track(obs, at.Add(time.Second))
if e1 != nil || e2 != nil {
return 0
}
return (b.RangeKm - a.RangeKm) / 2
}