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