Files
OpsLog/app_sat_track_test.go
T
rouggyandClaude Opus 5 3ac6f7e49c fix(sat): the Flex slices get their antenna, their sideband and their tone
Three things the tracker was leaving to chance on a FlexRadio, all reported from
a real pass.

ANTENNAS. Settings ▸ FlexRadio holds a per-band RX/TX antenna map, and it was
applied in exactly one place: the entry form, on a band change, to the active
slice. A pass never goes through that path — the tracker arms two slices itself.
So both were left on whatever the radio last used, and a station with
transverters (XVTA on 2 m, XVTB on 70 cm) heard nothing at all, having
configured precisely the thing being ignored. The two slices are on two
different bands, so they cannot share one setting: the downlink takes the
receive antenna for ITS band, the uplink the transmit antenna for its. Per
slice, not through sendSlice, which addresses whichever slice is active — during
a pass that is the downlink, so the uplink would never have been set.

SIDEBAND. satMode forced USB above 30 MHz on both sides. An inverting
transponder turns the passband over, so lower sideband up comes back as upper
sideband down: FO-29, RS-44 and AO-73 were being worked with the operator's own
audio going through upside down. The tracker now decides both sidebands from the
transponder's inverting flag and passes them separately; a bare "SSB" still
means USB, so nothing else changes.

CTCSS. Nothing set it, on any bird. The frequency plan has carried the tone all
along — 67.0 on SO-50 and AO-91, 141.3 on PO-101 — and an FM repeater does not
answer without it, which is indistinguishable from a satellite that is not
there. It goes on the uplink slice, value before mode so the radio cannot
transmit the previous tone in the gap between two commands.

Written against the SmartSDR slice API and UNTESTED on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-09 20:20:41 +02:00

138 lines
4.7 KiB
Go

package main
import (
"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)
}
}
}