Files
OpsLog/app_sat_track.go
T
rouggy 2283734210 feat(sat): PstRotator can point the antenna too
It handles azimuth and elevation, and a great many stations already run
it in front of a controller OpsLog has never heard of. For those,
OpsLog talking to the controller itself would be a second program
fighting PstRotator over the same cable — so it hands over the bearing
instead, and lets PstRotator turn the mast.

Both kinds sit behind one small interface, chosen in Settings. Neither is
more correct than the other: the right one is whichever the station
already has working.

The 450° overlap is deliberately NOT applied on the PstRotator path.
PstRotator knows which machine is on the other end and does its own; two
programs each deciding to go the long way round is exactly how an antenna
unwinds in the middle of a pass.

Position queries are asked at most every three seconds rather than on
every tick. A PstRotator query binds a socket and waits up to a second
and a half, and many setups answer nothing at all — so one silence is
enough and it stops asking, reporting the commanded position instead and
saying that is what it is.
2026-09-07 17:11:23 +02:00

623 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// Doppler tracking — walking the radio through a pass.
//
// The hard part of satellite tuning is not the arithmetic, it is deciding who
// owns the dial. A tracker that simply forces both frequencies fights the
// operator every time they turn the knob to follow a station across a linear
// transponder, and one that never touches the receiver leaves them chasing a
// signal that slides 9 kHz across a 70 cm pass.
//
// So: the operator owns the receiver, and the tracker follows them. Every tick
// it asks the radio where the receiver actually is. If that is where the tracker
// put it, nothing has changed and it keeps correcting from the same NOMINAL
// frequency. If it has moved further than a dial-turn's tolerance, the operator
// has chosen a new station: the tracker converts what they landed on back into a
// nominal frequency and carries on from there. The transmitter is derived from
// the nominal and never argued with — which is exactly the division of labour on
// a linear bird, where the operator listens and the radio does the sums.
import (
"fmt"
"math"
"strings"
"sync"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"hamlog/internal/applog"
"hamlog/internal/cat"
"hamlog/internal/qso"
"hamlog/internal/sat"
)
// satTickEvery is how often the radio is re-pointed. One second: at the middle
// of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on
// SSB within two or three of them and inaudible within one.
const satTickEvery = time.Second
// satDialTolerance is how far the receiver may differ from where the tracker put
// it before that difference is read as the operator tuning.
//
// 200 Hz is comfortably more than the rounding and the round-trip lag between
// setting a frequency and reading it back, and comfortably less than the
// smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200
// satLightKmS is the speed of light in km/s, for turning a heard frequency back
// into a nominal one. The same constant internal/sat corrects with.
const satLightKmS = 299792.458
type satTracker struct {
name string
tp int
mu sync.Mutex
// nominalDown is where the operator is, expressed as if the satellite were
// standing still. Everything else is derived from it, and it is the only
// thing a dial movement changes.
nominalDown int64
lastDown int64 // what was last sent to the radio
lastUp int64
status SatTrackStatus
fails int
// The az/el rotator, built once at the start of the pass so a serial port is
// opened once rather than on every command. nil when none is configured.
rot satRotator
rotStep float64
rotMinE float64
rotPark bool
rotAz float64 // last commanded, so a step smaller than the beamwidth costs nothing
rotEl float64
rotSent bool
rotReadAt time.Time // when the controller was last asked where it is
stop chan struct{}
done chan struct{}
}
// SatTrackStatus is what the tracker is doing, for the panel.
type SatTrackStatus struct {
On bool `json:"on"`
Name string `json:"name"`
Transponder string `json:"transponder"`
Mode string `json:"mode"`
NominalDown int64 `json:"nominal_down"`
NominalUp int64 `json:"nominal_up"`
DownHz int64 `json:"down_hz"`
UpHz int64 `json:"up_hz"`
Az float64 `json:"az"`
El float64 `json:"el"`
Visible bool `json:"visible"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"`
// Where the antenna is. RotLive distinguishes a reading from the controller
// from the last position it was TOLD to go to — a stuck rotator must not be
// able to hide behind a command it never carried out.
RotOn bool `json:"rot_on"`
RotAz float64 `json:"rot_az"`
RotEl float64 `json:"rot_el"`
RotLive bool `json:"rot_live"`
}
// StartSatelliteTracking arms the radio and starts following the satellite.
func (a *App) StartSatelliteTracking(name string, transponder int) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
_, birds, _ := a.satParts()
b, ok := birds.Find(name)
if !ok || len(b.Transponders) == 0 {
return fmt.Errorf("%s has no frequency plan to tune to", name)
}
if transponder < 0 || transponder >= len(b.Transponders) {
transponder = 0
}
a.StopSatelliteTracking()
t := &satTracker{
name: b.Name,
tp: transponder,
nominalDown: b.Transponders[transponder].Centre(),
stop: make(chan struct{}),
done: make(chan struct{}),
}
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The rotator, if there is one. A geostationary bird is pointed at once and
// left alone, so it gets one command rather than a loop.
set := a.satSettings()
if set.RotOn {
r, rerr := newSatRotator(set)
if rerr != nil {
applog.Printf("sat: no rotator: %v", rerr)
t.status.Error = rerr.Error()
} else {
t.rot = r
t.rotStep, t.rotMinE, t.rotPark = float64(set.RotStep), float64(set.RotMinEl), set.RotPark
}
}
// Arm the radio for the pair. A rig that cannot hold one is NOT a failure:
// it can still be tuned to the downlink, which is most of a receive-heavy
// pass, and saying so beats refusing to track at all.
radio := "downlink-only"
if a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil {
applog.Printf("sat: could not arm satellite mode: %v", err)
t.status.Error = err.Error()
} else {
radio = "sat"
}
}
t.status.Radio = radio
a.satTrackMu.Lock()
a.satTrack = t
a.satTrackMu.Unlock()
go a.satTrackLoop(t)
applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio)
return nil
}
// StopSatelliteTracking hands the radio back.
func (a *App) StopSatelliteTracking() {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrack = nil
a.satTrackMu.Unlock()
if t == nil {
return
}
close(t.stop)
<-t.done
if a.cat != nil && a.cat.SatCapable() {
if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil {
applog.Printf("sat: could not disarm satellite mode: %v", err)
}
}
applog.Printf("sat: tracking stopped (%s)", t.name)
a.emitSatTrack(SatTrackStatus{})
}
// TestSatelliteRotator opens the configured controller and asks it where it is.
//
// The one question worth asking before a pass: is this port the rotator, and
// does it talk back? A controller that accepts commands silently is a normal,
// working one — so that answer is a success with a caveat, not a failure.
func (a *App) TestSatelliteRotator() (string, error) {
set := a.satSettings()
if !set.RotOn {
return "", fmt.Errorf("the satellite rotator is switched off")
}
c, err := newSatRotator(set)
if err != nil {
return "", err
}
defer c.Close()
az, el, live, err := c.Heading()
if err != nil {
return "", err
}
if !live {
return "The controller accepted the command but does not report its position — normal for many controllers. It will still be driven.", nil
}
return fmt.Sprintf("The rotator is at %.1f° azimuth, %.1f° elevation.", az, el), nil
}
// GetSatelliteTracking reports what the tracker is doing.
func (a *App) GetSatelliteTracking() SatTrackStatus {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return SatTrackStatus{}
}
t.mu.Lock()
defer t.mu.Unlock()
return t.status
}
// satTrackedNominal is the nominal downlink the tracker is currently working
// from, or 0 when it is not tracking this satellite and transponder.
func (a *App) satTrackedNominal(name string, transponder int) int64 {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
return t.nominalDown
}
func (a *App) emitSatTrack(s SatTrackStatus) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "sat:track", s)
}
}
func (a *App) satTrackLoop(t *satTracker) {
defer close(t.done)
defer t.releaseRotator()
tick := time.NewTicker(satTickEvery)
defer tick.Stop()
for {
a.satTrackStep(t)
select {
case <-t.stop:
return
case <-tick.C:
}
}
}
// satTrackStep is one pass of the loop: read the dial, work out the pair, send
// what changed.
func (a *App) satTrackStep(t *satTracker) {
_, birds, _ := a.satParts()
b, ok := birds.Find(t.name)
if !ok || t.tp >= len(b.Transponders) {
return
}
tp := b.Transponders[t.tp]
t.mu.Lock()
nominal := t.nominalDown
lastDown, lastUp := t.lastDown, t.lastUp
t.mu.Unlock()
// Where the satellite is, and how fast it is running away. A geostationary
// bird is neither: its range rate is zero, so the zero position below gives
// a zero shift without a special case, and asking for a look angle we do not
// need would only fail on a station with no locator.
var pos sat.Position
visible := true
if !b.Geostationary {
obs, err := a.satObserver()
if err != nil {
t.setError(err.Error())
return
}
real, ok := a.satResolve(t.name)
if !ok {
t.setError(fmt.Sprintf("%s is not in the element set", t.name))
return
}
store, _, _ := a.satParts()
p, err := store.Track(real, obs, time.Now().UTC())
if err != nil {
t.setError(err.Error())
return
}
pos = p
visible = p.Visible()
}
// The fractional shift, positive when the satellite is approaching. Only the
// dial arithmetic below needs it as a number; the pair itself comes from
// sat.Doppler, so there is exactly one place where the sign of a correction
// is decided.
factor := -pos.RangeRate / satLightKmS
// Where did the operator leave the receiver? If it is not where the tracker
// put it, they have moved to another station and that is the new nominal.
if lastDown > 0 && tp.Linear() {
if actual, err := a.satReceiveHz(); err == nil && actual > 0 {
if abs64i(actual-lastDown) > satDialTolerance {
moved := satNominalFromDial(actual, factor)
if moved >= tp.DownLo && moved <= tp.DownHi {
nominal = moved
t.mu.Lock()
t.nominalDown = moved
t.mu.Unlock()
}
}
}
}
nomUp := tp.UplinkFor(nominal)
sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz
t.mu.Lock()
t.status = SatTrackStatus{
On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode,
NominalDown: nominal, NominalUp: nomUp,
DownHz: down, UpHz: up,
Az: pos.Az, El: pos.El, Visible: visible,
Radio: t.status.Radio, Error: t.status.Error,
}
t.mu.Unlock()
t.pointRotator(pos, b.Geostationary)
t.readRotator()
t.mu.Lock()
st := t.status
t.mu.Unlock()
a.emitSatTrack(st)
// Only send what has actually moved. The step is the smallest change worth a
// command: on SSB a listener hears twenty hertz, on an FM channel nothing
// under a couple of hundred matters at all.
step := int64(20)
if strings.EqualFold(tp.Mode, "FM") {
step = 200
}
if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step {
return
}
mode := tp.Mode
if lastDown != 0 {
mode = "" // set once, at the start of the pass — see satMode/satSetMode
}
err := a.satTune(down, up, mode, mode)
t.mu.Lock()
if err == nil {
t.lastDown, t.lastUp, t.fails = down, up, 0
t.status.Error = ""
} else {
t.fails++
t.status.Error = err.Error()
}
fails := t.fails
t.mu.Unlock()
if err != nil && (fails == 1 || fails%30 == 0) {
// Once, then once every half minute: a radio that has gone away must be
// visible in the log without filling it.
applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err)
}
}
// satNominalFromDial turns a frequency the operator tuned to into the nominal
// one it corresponds to.
//
// The inverse of the downlink correction: what comes out of the transponder at
// nominal arrives at heard = nominal × (1 + f). Doing this is what lets the
// operator hunt across a linear passband without the tracker dragging them back
// — where they land becomes the new truth, and the uplink follows it.
func satNominalFromDial(heardHz int64, factor float64) int64 {
if heardHz <= 0 || factor <= -1 {
return heardHz
}
return int64(math.Round(float64(heardHz) / (1 + factor)))
}
// pointRotator keeps the antenna on the satellite.
//
// Below the configured elevation the rotator is left alone. Not because the
// numbers stop being right — they are right all the way round the orbit — but
// because a rotator that chases a satellite through the far side of the earth
// spends the whole night turning, and a mast is a mechanical thing with a
// finite number of turns in it.
func (t *satTracker) pointRotator(pos sat.Position, geostationary bool) {
if t.rot == nil {
return
}
if !geostationary && pos.El < t.rotMinE {
return
}
// A step below the beamwidth is a command for nothing. Compared against what
// was last COMMANDED rather than where the rotator says it is: a rotator in
// motion is always somewhere between the two, and comparing against that
// would order a fresh move on every tick of a slew.
az, el := pos.Az, pos.El
if geostationary {
// A satellite that does not move needs pointing once. Its own az/el were
// not computed (there is nothing to compute), so leave the rotator where
// the operator put it.
if t.rotSent {
return
}
}
if t.rotSent && math.Abs(az-t.rotAz) < t.rotStep && math.Abs(el-t.rotEl) < t.rotStep {
return
}
if err := t.rot.Point(az, el); err != nil {
t.setError(err.Error())
return
}
t.rotAz, t.rotEl, t.rotSent = az, el, true
}
// readRotator asks the controller where it actually is, for the display.
//
// Separate from the pointing, and it runs on every tick rather than only when a
// command was sent: watching the antenna crawl towards the bearing is how an
// operator sees a rotator that is slow, stalled, or turning the wrong way. A
// controller that does not answer says so once and is not asked again.
func (t *satTracker) readRotator() {
if t.rot == nil {
return
}
// Not on every tick. A PstRotator query binds a socket and waits up to a
// second and a half for an answer, and a held serial port still costs a
// round trip; three seconds is often enough to watch an antenna slew and
// rare enough not to sit in the way of the tuning.
if time.Since(t.rotReadAt) < 3*time.Second {
return
}
t.rotReadAt = time.Now()
az, el, live, err := t.rot.Heading()
t.mu.Lock()
defer t.mu.Unlock()
if err != nil {
t.status.RotOn = true
return
}
t.status.RotOn, t.status.RotAz, t.status.RotEl, t.status.RotLive = true, az, el, live
}
// releaseRotator hands the mast back at the end of a pass.
func (t *satTracker) releaseRotator() {
if t.rot == nil {
return
}
if t.rotPark && t.rotSent {
// Elevation down first and azimuth to north: a dish or a pair of yagis
// left pointing at the sky is what a gale takes away.
if err := t.rot.Point(0, 0); err != nil {
applog.Printf("sat: could not park the rotator: %v", err)
}
}
t.rot.Close()
t.rot = nil
}
func (t *satTracker) setError(msg string) {
t.mu.Lock()
t.status.Error = msg
t.mu.Unlock()
}
// satTune sends the pair to whichever radio is connected.
func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error {
if a.cat == nil {
return fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
return a.cat.SatDo(func(st cat.SatTuner) error {
return st.TuneSatellite(downHz, upHz, downMode, upMode)
})
}
// No satellite pair on this backend: the downlink is what it can do, and the
// operator was told so when tracking started (Radio = "downlink-only").
if err := a.cat.SetFrequency(downHz); err != nil {
return err
}
if downMode != "" {
return a.cat.SetMode(downMode)
}
return nil
}
// satReceiveHz is where the receiver is, asked of the backend that knows.
func (a *App) satReceiveHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if a.cat.SatCapable() {
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatReceiveHz()
hz = v
return e
})
return hz, err
}
st := a.cat.State()
if st.RxFreqHz > 0 {
return st.RxFreqHz, nil
}
return st.FreqHz, nil
}
func abs64i(v int64) int64 {
if v < 0 {
return -v
}
return v
}
// ── What goes in the log ────────────────────────────────────────────────────
// applySatellite stamps a QSO made through a satellite.
//
// The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two
// stations working each other through a transponder read different numbers off
// their dials at the same instant — that is what Doppler means — and the only
// figure they can both agree on, and the only one that means anything to
// somebody reading the log later, is the transponder's own. LoTW matches on the
// band, so nothing is lost; a log full of 435.847 231 would simply be a record
// of where one radio happened to be.
func (a *App) applySatellite(q *qso.QSO) {
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t == nil {
return
}
t.mu.Lock()
name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp
az, el := t.status.Az, t.status.El
t.mu.Unlock()
if name == "" || down <= 0 {
return
}
// Nothing the operator filled in is overwritten. A QSO edited by hand, or
// imported, or logged from a second radio while the tracker happened to be
// running, keeps what it was given.
if strings.TrimSpace(q.PropMode) == "" {
q.PropMode = "SAT"
}
if q.PropMode != "SAT" {
return // they said it was something else — meteor scatter, EME
}
if strings.TrimSpace(q.SatName) == "" {
q.SatName = name
}
if strings.TrimSpace(q.SatMode) == "" {
q.SatMode = satModeLetters(up, down)
}
// The transmit frequency is the uplink and the receive frequency the
// downlink — which is the one place a satellite QSO differs from every other
// kind, and the reason FREQ alone cannot describe one.
if up > 0 {
q.FreqHz = &up
if b := bandForHz(up); b != "" {
q.Band = b
}
}
d := down
q.FreqRXHz = &d
if b := bandForHz(down); b != "" {
q.BandRX = b
}
if q.AntAz == nil && (az != 0 || el != 0) {
v := az
q.AntAz = &v
}
if q.AntEl == nil && el != 0 {
v := el
q.AntEl = &v
}
}
// satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the
// downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they
// are what every satellite operator writes on a QSL card.
func satModeLetters(upHz, downHz int64) string {
u, d := satBandLetter(upHz), satBandLetter(downHz)
if u == "" || d == "" {
return ""
}
return u + "/" + d
}
func satBandLetter(hz int64) string {
switch {
case hz <= 0:
return ""
case hz < 30_000_000:
return "A" // 10 m — mode A's downlink
case hz < 148_000_000:
return "V" // 2 m
case hz < 450_000_000:
return "U" // 70 cm
case hz < 1_300_000_000:
return "L" // 23 cm
case hz < 2_500_000_000:
return "S" // 13 cm
case hz < 6_000_000_000:
return "C" // 6 cm
case hz < 11_000_000_000:
return "X" // 3 cm
}
return "K" // 24 GHz and above
}