Files
OpsLog/app_sat_track_test.go
T
rouggy 72696a5c0c 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.
2026-09-10 18:53:03 +02:00

213 lines
7.6 KiB
Go

package main
import (
"sort"
"testing"
"hamlog/internal/sat"
)
// The dial arithmetic has to be the exact inverse of the correction, or every
// touch of the knob would nudge the nominal frequency a little further off and
// the uplink would walk across the passband over a pass.
func TestSatNominalFromDialRoundTrip(t *testing.T) {
// A range of range rates: hard approach, drifting, hard recession. ±8 km/s
// covers a low orbit overhead.
for _, rate := range []float64{-8, -3.2, -0.4, 0, 0.4, 3.2, 8} {
p := sat.Position{RangeRate: rate}
for _, nominal := range []int64{29_450_000, 145_900_000, 435_850_000, 10_489_675_000} {
sh := sat.Doppler(p, nominal, 0)
factor := -rate / satLightKmS
got := satNominalFromDial(sh.DownHz, factor)
if diff := got - nominal; diff > 1 || diff < -1 {
t.Errorf("rate %.1f km/s, %d Hz: heard %d, came back as %d (%+d)",
rate, nominal, sh.DownHz, got, diff)
}
}
}
}
// SAT_MODE is what goes on a QSL card, and the letters are the uplink's then
// the downlink's — the order operators write and the order ADIF wants.
func TestSatModeLetters(t *testing.T) {
for _, tc := range []struct {
name string
up, down int64
want string
}{
{"FO-29: 2 m up, 70 cm down", 145_950_000, 435_850_000, "V/U"},
{"AO-91: 70 cm up, 2 m down", 435_250_000, 145_960_000, "U/V"},
{"AO-7 mode A: 2 m up, 10 m down", 145_900_000, 29_450_000, "V/A"},
{"QO-100: 13 cm up, 3 cm down", 2_400_175_000, 10_489_675_000, "S/X"},
{"receive only", 0, 145_800_000, ""},
} {
if got := satModeLetters(tc.up, tc.down); got != tc.want {
t.Errorf("%s: got %q, wanted %q", tc.name, got, tc.want)
}
}
}
// Azimuth-only tracking must not command the rotor once a second.
//
// The step check used to compare BOTH axes, so with the elevation never
// commanded its difference stayed above the step for the whole pass and every
// tick sent the antenna to the bearing it was already on. A rotator is a
// mechanical thing with a finite number of turns in it.
func TestPointRotatorAzOnlyIgnoresElevation(t *testing.T) {
rec := &countingRotator{}
tr := &satTracker{rot: rec, rotStep: 5, rotAzOnly: true}
// The satellite climbs while the bearing barely moves — a pass going
// overhead from the side, which is the shape that provoked this.
for _, p := range []sat.Position{
{Az: 100, El: 5},
{Az: 101, El: 20},
{Az: 102, El: 45},
{Az: 103, El: 70},
} {
tr.pointRotator(p, false)
}
if rec.n != 1 {
t.Errorf("azimuth-only sent %d commands for 3° of bearing, want 1", rec.n)
}
if rec.lastAz != 100 {
t.Errorf("commanded azimuth %v, want the first one", rec.lastAz)
}
// And it still follows the azimuth when the azimuth actually moves.
tr.pointRotator(sat.Position{Az: 130, El: 70}, false)
if rec.n != 2 {
t.Errorf("a 30° swing was not followed: %d commands", rec.n)
}
}
// With an elevation axis, a climb is still followed.
func TestPointRotatorFollowsElevationWhenItCan(t *testing.T) {
rec := &countingRotator{}
tr := &satTracker{rot: rec, rotStep: 5}
tr.pointRotator(sat.Position{Az: 100, El: 5}, false)
tr.pointRotator(sat.Position{Az: 101, El: 40}, false)
if rec.n != 2 {
t.Errorf("a 35° climb was not followed: %d commands", rec.n)
}
if rec.lastEl != 40 {
t.Errorf("commanded elevation %v, want 40", rec.lastEl)
}
}
type countingRotator struct {
n int
lastAz, lastEl float64
}
func (c *countingRotator) Point(az, el float64) error {
c.n++
c.lastAz, c.lastEl = az, el
return nil
}
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
func (c *countingRotator) Close() {}
// Which sideband goes on each slice.
//
// An inverting transponder turns the passband over, so a signal transmitted on
// lower sideband comes back on upper. Setting USB at both ends put the
// operator's own audio through upside down — unreadable at the far end, and on
// FO-29, RS-44 and AO-73 that is every contact attempted.
func TestSatSidebands(t *testing.T) {
cases := []struct {
name string
tp sat.Transponder
wantDown, want string
}{
{"inverting linear: LSB up, USB down",
sat.Transponder{Mode: "SSB", Inverting: true}, "USB", "LSB"},
{"non-inverting linear: USB both ways",
sat.Transponder{Mode: "SSB"}, "USB", "USB"},
// A tone is transmitted and received in FM whichever way the passband
// runs, and CW is CW.
{"FM is FM both ways", sat.Transponder{Mode: "FM"}, "FM", "FM"},
{"CW ignores inversion", sat.Transponder{Mode: "CW", Inverting: true}, "CW", "CW"},
}
for _, c := range cases {
down, up := satSidebands(c.tp)
if down != c.wantDown || up != c.want {
t.Errorf("%s: got %s/%s, want %s/%s", c.name, down, up, c.wantDown, c.want)
}
}
}
// The per-band antenna map is keyed by the UPPERCASED band name, because that
// is what the settings panel writes. The satellite tracker looked its two bands
// up with the band plan's own spelling, matched nothing, and ran the pass on
// whichever antenna the radio was last left on — a 70 cm downlink through a 2 m
// transverter, with no error anywhere. This pins the contract in the direction
// that broke.
func TestFlexBandAntKeyIsUppercased(t *testing.T) {
for _, c := range []struct {
hz int64
want string
}{
{435_400_000, "70CM"}, // an FM bird's downlink
{145_950_000, "2M"}, // its uplink
{1_269_000_000, "23CM"}, // AO-92's L band
{29_450_000, "10M"}, // AO-7 mode A
{9_000_000_000, ""}, // nothing in the plan: no key, and no antenna
} {
if got := flexBandAntKey(c.hz); got != c.want {
t.Errorf("flexBandAntKey(%d) = %q, want %q", c.hz, got, c.want)
}
}
}
// A band list sorted as strings puts 10m between 1.25m and 12m, which is why
// the plan's own index is the order.
func TestBandOrderIsByFrequency(t *testing.T) {
got := []string{"70cm", "10m", "160m", "2m", "20m", "banana"}
sort.Slice(got, func(i, j int) bool { return bandOrder(got[i]) < bandOrder(got[j]) })
want := []string{"160m", "20m", "10m", "2m", "70cm", "banana"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sorted %v, want %v", got, want)
}
}
}
// 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)
}
}