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:
+180
-5
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user