Files
OpsLog/app_sat_track_test.go
T
rouggy 2615365684 fix(sat): the per-band antennas reach the satellite slices
Settings ▸ FlexRadio stores the band→antenna map keyed by the band name
in capitals — that is what the panel writes and what the entry form reads
back. applySatRadio looked its two bands up through bandForHz, which
returns the band plan's own spelling ("70cm"), so both lookups missed,
both antennas came back empty, and the early return left the pass on
whichever antenna the radio was last used on. An operator with XVTA on
2 m and XVTB on 70 cm had configured exactly the thing being ignored, and
nothing said so: the downlink ran through the wrong transverter in
silence.

The key is now computed by flexBandAntKey, which exists so the next
caller cannot make the same mistake, and a test pins the case in the
direction that broke. Both outcomes are logged — the resolved antennas,
or the bands nothing was configured for — because from the outside a
setting never made and a lookup that missed look identical.

Alongside, three things the same pass made obvious:

- Tracking shows the satellite's own azimuth, elevation, distance and
  altitude beside the frequencies. The strip held where the ANTENNA was
  pointing but not where the bird was, which is what says whether a pass
  is worth calling on. Elevation dims below the horizon so a satellite
  followed before its lever cannot be read as workable.

- Both satellite lists in the settings are sorted by name with the
  numbers taken as numbers. The available column followed the order
  birds.json happens to be written in and the followed column the order
  of the clicks, so finding one bird among sixteen meant reading all
  sixteen.

- A followed satellite that has since been renamed is resolved through
  the plan's aliases. LILACSAT-2 became LO-90, and the followed list is
  stored as plain text, so the bird the operator had chosen appeared as
  having no elements while the same satellite sat in the available
  column under its new name. Resolved in GetSatSettings rather than
  satSettings, which is read at startup before the plan is loaded.
2026-09-10 11:00:24 +02:00

161 lines
5.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() {}
// 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)
}
}
}