A satellite tracker that insists on an elevation motor is a tracker switched off for nearly everybody. A pass at the edge of the footprint — which is most of them — never climbs above ten or fifteen degrees for its whole length, and a yagi's beamwidth swallows that: the bearing alone is enough, and it is how most stations that work satellites are actually built. The same switch rescues an az/el station whose elevation motor has failed. So it is an option, not a silent fallback, because it does cost something: a bird straight overhead is a moving azimuth and a bearing that means nothing, and whether to accept that is the operator's call. With it on, any rotor in the list can be chosen — the PstRotator, the Rotator Genius, the ARCO, the tower already turned for HF. That works because the per-backend command dispatch moved out of the three RotatorGoTo/Stop/Heading methods into linkGoTo/linkStop/linkHeading, so the satellite tracker drives any of the seven backends through the same code the compass uses instead of a second implementation of each. GetRotatorHeading loses sixty lines of near-duplicate switch in the process, and a rotor with no elevation axis now says so (HasElevation) rather than reporting a zero that looks like a real bearing. One trap, with a test on it: the step check compared both axes, so with the elevation never commanded its difference stayed above the step for the whole pass and every tick ordered the antenna to the bearing it was already on. A mast has a finite number of turns in it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
109 lines
3.6 KiB
Go
109 lines
3.6 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() {}
|