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) } }