feat(sat): the uplink keeps the correction the operator makes

A transponder does not translate by exactly the published difference —
the oscillator on board is decades old on some birds and a kilohertz or
two out. So an operator who sounds right to themselves comes back off
frequency, corrects it on the transmit VFO, and the tracker put it back
one second later, every second, for the rest of the pass. Reported on an
IC-9700 against HRD, which keeps the shift the operator sets.

The tracker already worked this way for the RECEIVER: it reads the dial
back and treats a move as the operator choosing a new station. The
transmitter had no equivalent — its comment even said so, "derived from
the nominal and never argued with". Now it is read back too, and the
difference becomes a standing trim on the nominal uplink.

Applied to the nominal rather than the corrected frequency, because a
translation error is a fixed offset in the uplink band and not something
that scales with the Doppler. Read only while not transmitting: mid-over
nobody is turning the knob, and on an Icom this read switches to the SUB
band and back, which is the same path TuneSatellite already uses to
write the uplink and not something to do under a carrier.

Kept per satellite AND per transponder, because that is what it belongs
to: the error is a property of the hardware in orbit, stable from one
pass to the next. Capped at 20 kHz so a bad stored value cannot put the
station outside the passband for ever, and shown in the tune panel with
a reset — an offset taken silently from the VFO has to be visible, and
the VFO alone cannot bring it back to zero once the operator has drifted
somewhere wrong.

SatTuner gains SatTransmitHz, implemented for the native Icom satellite
mode and for the Flex uplink slice; anything else reports nothing and
the uplink is left to the arithmetic, as before.
This commit is contained in:
2026-09-10 18:53:03 +02:00
parent d0d29659cb
commit 72696a5c0c
11 changed files with 320 additions and 7 deletions
+180 -5
View File
@@ -20,6 +20,7 @@ package main
import (
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
@@ -45,6 +46,14 @@ const satTickEvery = time.Second
// smallest deliberate move anybody makes hunting a station on a transponder.
const satDialTolerance = 200
// satUpTrimLimit caps the uplink trim, in hertz.
//
// 20 kHz: wider than any transponder is off by, and narrower than the distance
// to a neighbouring band edge. It exists so a bad stored value, or a transmit
// VFO the operator swung across the band for some other reason, cannot become
// a permanent offset that puts the station outside the passband every pass.
const satUpTrimLimit = 20000
// 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
@@ -60,8 +69,21 @@ type satTracker struct {
nominalDown int64
lastDown int64 // what was last sent to the radio
lastUp int64
status SatTrackStatus
fails int
// upTrim is what the operator has added to the computed uplink, in hertz.
//
// A transponder does not translate by exactly the published difference: the
// oscillator on board is decades old on some birds and a kilohertz or two
// out. So an operator who sounds right to themselves comes back off
// frequency, corrects it on the transmit VFO — and the tracker put it back
// one second later, every second, for the whole pass. Reported on an IC-9700
// against HRD, which keeps the shift the operator sets.
//
// Read back from the radio rather than typed into a box, because the
// transmit VFO is the control an operator already reaches for, and it is
// exactly how the DOWNLINK dial is already handled a few lines below.
upTrim 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.
@@ -101,8 +123,12 @@ type SatTrackStatus struct {
// to know how long it will last.
RangeKm float64 `json:"range_km"`
AltKm float64 `json:"alt_km"`
Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", ""
Error string `json:"error"`
// UpTrimHz is the correction the operator has added to the uplink, in hertz.
// Shown so a trim taken silently from the transmit VFO is visible, and can be
// cleared — an offset nobody can see is a trap.
UpTrimHz int64 `json:"up_trim_hz"`
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
@@ -141,6 +167,14 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
wake: make(chan struct{}, 1),
}
t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode}
// The correction this transponder was last left with: its translation error
// is a property of the hardware in orbit and does not change between passes.
trim := a.loadSatUplinkTrim(b.Name, transponder)
t.upTrim = trim
t.status.UpTrimHz = trim
if trim != 0 {
applog.Printf("sat: uplink starts %+d Hz off nominal, as it was left", trim)
}
// 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.
@@ -338,7 +372,35 @@ func (a *App) satTrackStep(t *satTracker) {
}
}
nomUp := tp.UplinkFor(nominal)
// Where did the operator leave the TRANSMITTER? The same question as above,
// and the same answer: what they landed on is what they want, so the
// difference becomes a standing correction rather than being overwritten.
//
// Absorbed as a trim on the NOMINAL uplink, not on the corrected one: a
// transponder's translation error is a fixed offset in the uplink band, not
// something that scales with the Doppler. (The difference either way is
// under a hundredth of a hertz, but only one of the two is a reason.)
//
// Not while transmitting: mid-over the operator is not turning the knob, and
// on an Icom this read switches bands to reach the uplink — not something to
// do under a carrier.
if lastUp > 0 && !a.satTransmitting() {
if actual, err := a.satTransmitHz(); err == nil && actual > 0 {
if drift := actual - lastUp; abs64i(drift) > satDialTolerance {
t.mu.Lock()
t.upTrim += drift
trim := t.upTrim
t.mu.Unlock()
applog.Printf("sat: uplink trimmed by %+d Hz (now %+d Hz) — the transmit VFO moved", drift, trim)
a.saveSatUplinkTrim(b.Name, t.tp, trim)
}
}
}
t.mu.Lock()
upTrim := t.upTrim
t.mu.Unlock()
nomUp := tp.UplinkFor(nominal) + upTrim
sh := sat.Doppler(pos, nominal, nomUp)
down, up := sh.DownHz, sh.UpHz
@@ -356,6 +418,7 @@ func (a *App) satTrackStep(t *satTracker) {
st.DownHz, st.UpHz = down, up
st.Az, st.El, st.Visible = pos.Az, pos.El, visible
st.RangeKm, st.AltKm = pos.RangeKm, pos.AltKm
st.UpTrimHz = upTrim
t.status = st
t.mu.Unlock()
@@ -551,6 +614,114 @@ func (a *App) satReceiveHz() (int64, error) {
return st.FreqHz, nil
}
// satTransmitHz is where the transmitter actually is, or 0 when the radio
// cannot say. Only the satellite backends can: a rig working split reports one
// frequency and it is the receiver's.
func (a *App) satTransmitHz() (int64, error) {
if a.cat == nil {
return 0, fmt.Errorf("CAT is not running")
}
if !a.cat.SatCapable() {
return 0, nil
}
var hz int64
err := a.cat.SatDo(func(st cat.SatTuner) error {
v, e := st.SatTransmitHz()
hz = v
return e
})
return hz, err
}
// satTransmitting reports whether the rig is keyed, so the uplink readback can
// stay off the air while it is.
//
// Only the two backends that hold a satellite pair are asked, which are the
// only two this matters for. Unknown counts as NOT transmitting: refusing to
// read the uplink on a radio that cannot say would disable the trim entirely.
func (a *App) satTransmitting() bool {
if a.cat == nil {
return false
}
if st, ok := a.cat.FlexState(); ok {
return st.Transmitting
}
if st, ok := a.cat.IcomState(); ok {
return st.Transmitting
}
return false
}
// ── The uplink trim, remembered ─────────────────────────────────────────────
//
// Kept per satellite AND per transponder, because that is what it belongs to:
// a transponder's translation error is a property of the hardware in orbit,
// stable from one pass to the next and for years. An operator who found the
// right offset on FO-29 last week should not have to find it again tonight.
func keySatUpTrim(name string, tp int) string {
return fmt.Sprintf("sat.uptrim.%s.%d", strings.ToUpper(strings.TrimSpace(name)), tp)
}
func (a *App) loadSatUplinkTrim(name string, tp int) int64 {
if a.settings == nil {
return 0
}
v, _ := a.settings.Get(a.ctx, keySatUpTrim(name, tp))
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0
}
// A trim larger than the passband is a stored mistake, not a correction.
if n < -satUpTrimLimit || n > satUpTrimLimit {
return 0
}
return n
}
func (a *App) saveSatUplinkTrim(name string, tp int, hz int64) {
if a.settings == nil {
return
}
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
return
}
if err := a.settings.Set(a.ctx, keySatUpTrim(name, tp), strconv.FormatInt(hz, 10)); err != nil {
applog.Printf("sat: could not store the uplink trim: %v", err)
}
}
// GetSatUplinkTrim is what the panel shows.
func (a *App) GetSatUplinkTrim(name string, transponder int) int64 {
return a.loadSatUplinkTrim(name, transponder)
}
// SetSatUplinkTrim stores a trim and applies it to a pass in progress.
//
// The panel needs this to CLEAR one: a trim taken from the transmit VFO can
// only be adjusted by the same VFO, and an operator who has drifted somewhere
// wrong has no way back to zero without it.
func (a *App) SetSatUplinkTrim(name string, transponder int, hz int64) error {
if hz < -satUpTrimLimit || hz > satUpTrimLimit {
return fmt.Errorf("a %d Hz trim is outside anything a transponder is off by", hz)
}
a.saveSatUplinkTrim(name, transponder, hz)
a.satTrackMu.Lock()
t := a.satTrack
a.satTrackMu.Unlock()
if t != nil {
t.mu.Lock()
on := t.status.On && strings.EqualFold(t.name, name) && t.tp == transponder
if on {
t.upTrim = hz
}
t.mu.Unlock()
if on {
applog.Printf("sat: uplink trim set to %+d Hz", hz)
}
}
return nil
}
func abs64i(v int64) int64 {
if v < 0 {
return -v
@@ -788,6 +959,10 @@ func (a *App) RetargetSatelliteTracking(name string, transponder int) error {
// And so the antenna is commanded at once instead of waiting for the new
// satellite to drift a step away from where the old one happened to be.
t.rotSent = false
// A different transponder is off by a different amount, and the one we were
// on has no bearing on it.
t.upTrim = a.loadSatUplinkTrim(b.Name, transponder)
t.status.UpTrimHz = t.upTrim
t.status.Name, t.status.Transponder, t.status.Mode = b.Name, tp.Label, tp.Mode
t.status.Error = ""
t.mu.Unlock()