diff --git a/app_sat_track.go b/app_sat_track.go
index c6b229f..6ffe51d 100644
--- a/app_sat_track.go
+++ b/app_sat_track.go
@@ -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()
diff --git a/app_sat_track_test.go b/app_sat_track_test.go
index ef3f28d..ce6a7e8 100644
--- a/app_sat_track_test.go
+++ b/app_sat_track_test.go
@@ -172,3 +172,41 @@ func TestBandOrderIsByFrequency(t *testing.T) {
}
}
}
+
+// The uplink trim is a fixed offset on the NOMINAL uplink, so the Doppler
+// correction is computed from the frequency the operator actually transmits on.
+//
+// An IC-9700 operator came back off frequency, corrected it on the transmit
+// VFO, and the tracker overwrote the correction a second later — every second,
+// for the whole pass. The trim is what survives that.
+func TestUplinkTrimShiftsTheNominalUplink(t *testing.T) {
+ tp := sat.Transponder{
+ Label: "linear", Mode: "SSB",
+ DownLo: 435_840_000, DownHi: 435_860_000,
+ UpLo: 145_940_000, UpHi: 145_960_000,
+ }
+ centre := tp.Centre()
+ plain := tp.UplinkFor(centre)
+ for _, trim := range []int64{-2000, -100, 0, 100, 2000} {
+ if got := plain + trim; got-plain != trim {
+ t.Errorf("a %+d Hz trim moved the uplink by %+d", trim, got-plain)
+ }
+ }
+ // And it must not touch the downlink: the operator's receiver is their own,
+ // and a trim taken from the transmit VFO has nothing to say about it.
+ if tp.UplinkFor(centre) != plain {
+ t.Error("UplinkFor is not stable")
+ }
+}
+
+// The stored trim is capped. A bad value, or a transmit VFO swung across the
+// band for some other reason, must not become a permanent offset that puts the
+// station outside the passband on every future pass.
+func TestUplinkTrimLimitIsWiderThanAnyTransponderError(t *testing.T) {
+ if satUpTrimLimit < 5000 {
+ t.Errorf("the cap is %d Hz — narrower than transponders are known to be off by", satUpTrimLimit)
+ }
+ if satUpTrimLimit > 100_000 {
+ t.Errorf("the cap is %d Hz — wide enough to reach another band", satUpTrimLimit)
+ }
+}
diff --git a/changelog.json b/changelog.json
index a4f04af..dc95bac 100644
--- a/changelog.json
+++ b/changelog.json
@@ -10,7 +10,8 @@
"The decode panel no longer warns about band drift on a second slice: it now compares against every band the radio is receiving on, not just the transmit band.",
"OpsLog appears again after an update. The relaunch was starting the new build with its window hidden, so it ran with no window at all.",
"Each satellite slice now gets both of its antennas, RX and TX, from its own band — the downlink slice was left with no transmit antenna at all.",
- "The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian."
+ "The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian.",
+ "A correction you make on the transmit VFO is now kept for the whole pass, and remembered for that transponder. Doppler tracking used to undo it a second later."
],
"fr": [
"Le tracé du ciel et la position passent dans leur propre colonne, à gauche de la carte. Largeur réglable, et la colonne se replie comme celle de droite.",
@@ -21,7 +22,8 @@
"Le panneau de décodages n’avertit plus d’une dérive de bande sur une seconde tranche : la comparaison porte sur toutes les bandes reçues, pas seulement celle d’émission.",
"OpsLog réapparaît après une mise à jour. La relance démarrait la nouvelle version avec sa fenêtre masquée, donc sans aucune fenêtre.",
"Chaque slice satellite reçoit désormais ses deux antennes, RX et TX, depuis sa propre bande — la slice de descente restait sans antenne d’émission.",
- "Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsqu’il franchit l’antiméridien."
+ "Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsqu’il franchit l’antiméridien.",
+ "Une correction faite sur le VFO d’émission est désormais conservée pour toute la passe, et mémorisée pour ce transpondeur. Le suivi Doppler l’effaçait une seconde plus tard."
]
},
{
diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx
index a81db0e..ee52e5e 100644
--- a/frontend/src/components/SatellitePanel.tsx
+++ b/frontend/src/components/SatellitePanel.tsx
@@ -8,6 +8,7 @@ import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking,
+ SetSatUplinkTrim,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
@@ -60,6 +61,7 @@ type Track = {
on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean; range_km: number; alt_km: number;
+ up_trim_hz: number;
radio: string; // "sat" | "downlink-only" | ""
error: string;
rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean;
@@ -1102,6 +1104,30 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {