The hard part of satellite tuning is not the arithmetic, it is deciding who owns the dial. A tracker that forces both frequencies fights the operator every time they turn the knob to follow a station across a linear transponder; one that never touches the receiver leaves them chasing a signal that slides nine kilohertz across a 70 cm pass. So the operator owns the receiver and the tracker follows them. Every second it asks the radio where the receiver actually is. Where it put it, nothing has changed. Further than a dial-turn's tolerance, and the operator has chosen a station: what they landed on is converted back into a nominal frequency, and the transmitter is derived from that. Which is the division of labour on a linear bird — the operator listens, the radio does the sums. Three ways to reach the radio, because a satellite pair is a shape of operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is asked for its OWN satellite mode: it pairs main and sub, gives full duplex, and keeps the dials linked the way its designers meant, which is always better than an imitation built out of split. A Flex gets two slices, A the downlink and B the uplink, created when missing, because "slice B does not exist" is not something to make an operator fix at the start of a ten-minute pass. Everything else gets the downlink, and is told so — half the job announced beats half the job hidden. What goes in the log is the NOMINAL pair. Two stations working each other through a transponder read different numbers off their dials at the same instant; the only figure they can both agree on is the transponder's own. FREQ is the uplink and FREQ_RX the downlink — the one place a satellite QSO differs from every other kind, and the reason FREQ alone cannot describe one.
476 lines
14 KiB
Go
476 lines
14 KiB
Go
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
|
||
|
||
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"`
|
||
}
|
||
|
||
// 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}
|
||
|
||
// 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{})
|
||
}
|
||
|
||
// 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)
|
||
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()
|
||
a.emitSatTrack(t.status)
|
||
|
||
// 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)))
|
||
}
|
||
|
||
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
|
||
}
|