The FTx button is gone. It was the wrong grain in both directions: the Digital class already sat a contest RTTY square next to an FT8 one, and FTx then sat FT8 next to FT4, when what this map answers is where ONE mode has been heard. The four classes stay as buttons and a dropdown beside them offers a single mode. Its contents come from the log, not from a list in the code. That is what settles FT2: it is not a registered ADIF mode yet, so a hardcoded list meant either leaving out the operators already using it or shipping a mode that does not officially exist. A query does neither, and needs no change here the day it is registered. The mode offered is the SUBMODE where there is one, because ADIF files PSK63 under PSK and "PSK" is not the name anybody is looking for. The band list is the station's own, unioned with anything worked outside it so nothing in the log is unreachable, ordered by frequency through the band plan's index — sorting the names puts 10m between 1.25m and 12m. The satellite list is drawn from SAT_NAME on the squares themselves and the control is absent altogether on a terrestrial log: a VHF square worked through AO-91 and one worked line-of-sight are not the same achievement, and until now nothing separated them. The mode dropdown shares the scope with the class buttons rather than narrowing on top of them — mode is one question, and two controls both answering it is how a map ends up showing PHONE ∩ FT8 and nothing else. A stored FTX preference is read as Digital, so it cannot leave the map filtered by something no control shows as selected.
175 lines
6.1 KiB
Go
175 lines
6.1 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)
|
|
}
|
|
}
|
|
}
|