Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac6f7e49c | ||
|
|
0add56fb2d |
+74
-3
@@ -158,6 +158,7 @@ func (a *App) StartSatelliteTracking(name string, transponder int) error {
|
|||||||
t.status.Error = err.Error()
|
t.status.Error = err.Error()
|
||||||
} else {
|
} else {
|
||||||
radio = "sat"
|
radio = "sat"
|
||||||
|
a.applySatRadio(b.Transponders[transponder])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.status.Radio = radio
|
t.status.Radio = radio
|
||||||
@@ -359,11 +360,12 @@ func (a *App) satTrackStep(t *satTracker) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mode := tp.Mode
|
downMode, upMode := satSidebands(tp)
|
||||||
if lastDown != 0 {
|
if lastDown != 0 {
|
||||||
mode = "" // set once, at the start of the pass — see satMode/satSetMode
|
// Set once, at the start of the pass — see satMode/satSetMode.
|
||||||
|
downMode, upMode = "", ""
|
||||||
}
|
}
|
||||||
err := a.satTune(down, up, mode, mode)
|
err := a.satTune(down, up, downMode, upMode)
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.lastDown, t.lastUp, t.fails = down, up, 0
|
t.lastDown, t.lastUp, t.fails = down, up, 0
|
||||||
@@ -634,3 +636,72 @@ func satBandLetter(hz int64) string {
|
|||||||
}
|
}
|
||||||
return "K" // 24 GHz and above
|
return "K" // 24 GHz and above
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySatAntennas puts each satellite slice on the antenna configured for ITS
|
||||||
|
// band.
|
||||||
|
//
|
||||||
|
// Settings ▸ FlexRadio already holds a per-band RX/TX antenna map, and it was
|
||||||
|
// only ever applied by the entry form on a band change — to the active slice.
|
||||||
|
// A pass never goes through that path: the tracker arms two slices itself, on
|
||||||
|
// two different bands, and both were left on whatever the radio last used. A
|
||||||
|
// station with transverters (XVTA on 2 m, XVTB on 70 cm) therefore heard
|
||||||
|
// nothing at all, having configured exactly the thing that was being ignored.
|
||||||
|
//
|
||||||
|
// The bands come from the NOMINAL frequencies, not the Doppler-corrected ones:
|
||||||
|
// a correction of ten kilohertz cannot change the band, and the nominal pair is
|
||||||
|
// what the operator's configuration is written against.
|
||||||
|
func (a *App) applySatRadio(tp sat.Transponder) {
|
||||||
|
if a.cat == nil || !a.cat.SatCapable() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The CTCSS tone first: an FM bird will not answer without it, and it is the
|
||||||
|
// one setting an operator cannot make from the front panel once a pass has
|
||||||
|
// started. Zero turns it off, which is what a linear bird needs.
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.SatTone(tp.CTCSS)
|
||||||
|
}); err != nil {
|
||||||
|
applog.Printf("sat: could not set the uplink tone: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := a.GetFlexBandAntennas()
|
||||||
|
if err != nil || len(m) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The downlink is received, so it takes that band's RX antenna; the uplink
|
||||||
|
// is transmitted, so it takes that band's TX antenna.
|
||||||
|
rxAnt := m[bandForHz(tp.DownLo)].RX
|
||||||
|
txAnt := m[bandForHz(tp.UpLo)].TX
|
||||||
|
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
return fc.SatAntennas(rxAnt, txAnt)
|
||||||
|
}); err != nil {
|
||||||
|
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||||
|
// the wrong antenna is still a pass.
|
||||||
|
applog.Printf("sat: could not set the satellite antennas: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// satSidebands is which sideband to set on each side of a linear transponder.
|
||||||
|
//
|
||||||
|
// The two are NOT the same when the transponder inverts, and FO-29, RS-44 and
|
||||||
|
// AO-73 all do: the passband is turned over, so a signal transmitted on lower
|
||||||
|
// sideband comes back on upper. Setting USB at both ends — which is what
|
||||||
|
// happened until now — put the operator's own audio through the transponder
|
||||||
|
// upside down, which is unreadable at the far end and sounds like nothing much
|
||||||
|
// at ours.
|
||||||
|
//
|
||||||
|
// Anything that is not SSB is the same on both sides: an FM repeater is FM up
|
||||||
|
// and FM down, and CW is CW whichever way round the passband runs.
|
||||||
|
func satSidebands(tp sat.Transponder) (downMode, upMode string) {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(tp.Mode), "SSB") {
|
||||||
|
return tp.Mode, tp.Mode
|
||||||
|
}
|
||||||
|
// Every satellite is above 30 MHz, so the downlink is upper sideband — even
|
||||||
|
// on the AO-7 10 m downlink, which would be lower sideband on HF.
|
||||||
|
if tp.Inverting {
|
||||||
|
return "USB", "LSB"
|
||||||
|
}
|
||||||
|
return "USB", "USB"
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,3 +106,32 @@ func (c *countingRotator) Point(az, el float64) error {
|
|||||||
}
|
}
|
||||||
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
|
func (c *countingRotator) Heading() (float64, float64, bool, error) { return 0, 0, false, nil }
|
||||||
func (c *countingRotator) Close() {}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.21",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The Doppler correction was wrong — by a factor of about 250, and in the wrong direction. The SGP4 library reports a range rate that is not one: the ISS closing at 5.5 km/s came back as +2036 km/s, which moved a 2 m downlink two megahertz instead of three kilohertz, and moved it the wrong way. OpsLog now measures the range rate from the range itself, which cannot disagree with physics. A 2 m downlink shifts about ±3.5 kHz across a pass and a 70 cm one about ±10 kHz, as they should.",
|
||||||
|
"FlexRadio, satellite: the per-band antennas you configured are now applied to the satellite slices. They were not — the entry form applied them on a band change, to the active slice, and a pass never goes through that path. The two slices are on two different bands, so each gets its own: the downlink takes the receive antenna for its band, the uplink the transmit antenna for its. On a station with transverters (XVTA on 2 m, XVTB on 70 cm) the downlink was left on whatever the radio last used, and heard nothing.",
|
||||||
|
"FlexRadio, satellite: on an inverting transponder the uplink is set to LSB and the downlink to USB, instead of USB at both ends. The passband is turned over, so audio transmitted on the wrong sideband comes back through it upside down — which is every attempted contact on FO-29, RS-44 and AO-73.",
|
||||||
|
"FlexRadio, satellite: the CTCSS tone is set on the uplink slice from the satellite's frequency plan. An FM bird does not answer without it, and it is the one setting an operator cannot reach from the front panel once a pass has started."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"La correction Doppler était fausse — d’un facteur d’environ 250, et dans le mauvais sens. La bibliothèque SGP4 renvoie une vitesse radiale qui n’en est pas une : l’ISS se rapprochant à 5,5 km/s était rapportée à +2036 km/s, ce qui déplaçait une descente 2 m de deux mégahertz au lieu de trois kilohertz, et dans la mauvaise direction. OpsLog mesure désormais cette vitesse à partir de la distance elle-même, ce qui ne peut pas contredire la physique. Une descente 2 m se décale d’environ ±3,5 kHz sur un passage et une 70 cm d’environ ±10 kHz, comme il se doit.",
|
||||||
|
"FlexRadio, satellite : les antennes par bande que vous avez configurées sont désormais appliquées aux tranches satellite. Elles ne l’étaient pas — la fenêtre de saisie les appliquait au changement de bande, sur la tranche active, et un passage ne passe jamais par là. Les deux tranches sont sur deux bandes différentes, donc chacune reçoit la sienne : la descente prend l’antenne de réception de sa bande, la montée l’antenne d’émission de la sienne. Sur une station à transverters (XVTA en 2 m, XVTB en 70 cm), la descente restait sur ce que la radio utilisait en dernier, et n’entendait rien.",
|
||||||
|
"FlexRadio, satellite : sur un transpondeur inverseur, la montée est mise en LSB et la descente en USB, au lieu d’USB des deux côtés. La bande passante est retournée : une audio émise sur la mauvaise bande latérale revient à l’envers — soit tous les QSO tentés sur FO-29, RS-44 et AO-73.",
|
||||||
|
"FlexRadio, satellite : la tonalité CTCSS est réglée sur la tranche de montée depuis le plan de fréquences du satellite. Un satellite FM ne répond pas sans elle, et c’est le seul réglage qu’un OM ne peut pas atteindre en façade une fois le passage commencé."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.20",
|
"version": "0.27.20",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// Command satdiag answers "is this pass real, and is that Doppler right?" from
|
||||||
|
// a station's own cached elements, without launching OpsLog.
|
||||||
|
//
|
||||||
|
// go run ./cmd/satdiag <data dir> <locator> <satellite>
|
||||||
|
//
|
||||||
|
// It prints which element set the satellite resolved to and how old it is, the
|
||||||
|
// look angle now, the range rate BOTH as the propagator reports it and as the
|
||||||
|
// range actually changes, the Doppler each transponder would be given, and the
|
||||||
|
// next passes. It exists because a wrong Doppler and a wrong satellite look the
|
||||||
|
// same from the front — an operator saying "the frequency moves enormously" —
|
||||||
|
// and the two are told apart by these numbers in a second.
|
||||||
|
//
|
||||||
|
// It found the range rate the SGP4 library reports being wrong by a factor of
|
||||||
|
// 250 and of the wrong sign. Not part of the build.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dir := os.Args[1]
|
||||||
|
grid := os.Args[2]
|
||||||
|
name := os.Args[3]
|
||||||
|
|
||||||
|
f := sat.NewFetcher(dir)
|
||||||
|
els, at, err := f.LoadCache()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("cache:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
store := sat.NewStore()
|
||||||
|
store.Replace(els, at)
|
||||||
|
fmt.Printf("elements: %d, fetched %s (%s ago)\n\n", len(els), at.Format(time.RFC3339), time.Since(at).Round(time.Minute))
|
||||||
|
|
||||||
|
birds, err := sat.LoadBirds(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("birds:", err)
|
||||||
|
}
|
||||||
|
b, ok := birds.Find(name)
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("no frequency plan for", name)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// The same resolution the app does.
|
||||||
|
var el sat.Element
|
||||||
|
found := false
|
||||||
|
if e, ok := store.GetNORAD(b.NORAD); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found BY NORAD %d → %q\n", b.NORAD, e.Name)
|
||||||
|
} else if e, ok := store.Get(b.Name); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found by name → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||||
|
} else {
|
||||||
|
for _, a := range b.Aliases {
|
||||||
|
if e, ok := store.Get(a); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found by alias %q → %q (NORAD %d)\n", a, e.Name, e.NORAD)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
// Last resort, exactly as satElement does: scan every element name and
|
||||||
|
// compare on letters and digits alone. This is how "JAS-2 (FO-29)" and
|
||||||
|
// "FO-29" meet, and leaving it out of the diagnostic made a satellite
|
||||||
|
// that resolves perfectly well in the app look unresolvable here.
|
||||||
|
for _, n := range store.Names() {
|
||||||
|
if b.Matches(n) {
|
||||||
|
if e, ok := store.Get(n); ok {
|
||||||
|
el, found = e, true
|
||||||
|
fmt.Printf("elements found by SCAN → %q (NORAD %d)\n", e.Name, e.NORAD)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
fmt.Println("NO ELEMENTS")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("epoch: %s (%s old)\n", el.Epoch.Format(time.RFC3339), time.Since(el.Epoch).Round(time.Hour))
|
||||||
|
fmt.Println("line1:", el.Line1)
|
||||||
|
|
||||||
|
lat, lon, okGrid := gridToLatLon(grid)
|
||||||
|
if !okGrid {
|
||||||
|
fmt.Println("bad locator:", grid)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
obs := sat.Observer{Lat: lat, Lon: lon}
|
||||||
|
fmt.Printf("observer: %s → %.4f, %.4f\n\n", grid, obs.Lat, obs.Lon)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
p, err := el.Track(obs, now)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("track:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("NOW %s : az %.1f el %.1f range %.0f km\n", now.Format("15:04:05"), p.Az, p.El, p.RangeKm)
|
||||||
|
fmt.Printf(" range rate REPORTED by the library : %+10.3f km/s\n", p.RangeRate)
|
||||||
|
fmt.Printf(" range rate MEASURED (d range / dt) : %+10.3f km/s\n", numericRate(el, obs, now))
|
||||||
|
|
||||||
|
for _, tp := range b.Transponders {
|
||||||
|
sh := sat.Doppler(p, tp.DownLo, tp.UpLo)
|
||||||
|
fmt.Printf(" %-28s down %d → %d (%+d Hz) up %d → %d (%+d Hz)\n",
|
||||||
|
tp.Label, tp.DownLo, sh.DownHz, sh.DownHz-tp.DownLo, tp.UpLo, sh.UpHz, sh.UpHz-tp.UpLo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nnext passes (min el 0):")
|
||||||
|
passes, err := store.Passes(el.Name, obs, now, now.Add(12*time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("passes:", err)
|
||||||
|
}
|
||||||
|
for i, ps := range passes {
|
||||||
|
if i >= 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s → %s max %.1f° az %.0f→%.0f\n",
|
||||||
|
ps.AOS.Format("15:04:05"), ps.LOS.Format("15:04:05"), ps.MaxEl, ps.AOSAz, ps.LOSAz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The extremes of the Doppler across the next pass, which is the honest
|
||||||
|
// answer to "does it move that much".
|
||||||
|
if len(passes) > 0 {
|
||||||
|
ps := passes[0]
|
||||||
|
var lo, hi int64
|
||||||
|
for tt := ps.AOS; tt.Before(ps.LOS); tt = tt.Add(10 * time.Second) {
|
||||||
|
q, err := el.Track(obs, tt)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := sat.Doppler(q, b.Transponders[0].DownLo, 0).DownHz - b.Transponders[0].DownLo
|
||||||
|
if d < lo {
|
||||||
|
lo = d
|
||||||
|
}
|
||||||
|
if d > hi {
|
||||||
|
hi = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("\ndownlink Doppler across that pass: %+d Hz … %+d Hz (span %d Hz)\n", lo, hi, hi-lo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gridToLatLon is the six-character Maidenhead centre.
|
||||||
|
func gridToLatLon(g string) (float64, float64, bool) {
|
||||||
|
g = strings.ToUpper(strings.TrimSpace(g))
|
||||||
|
if len(g) < 4 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
lon := float64(g[0]-'A')*20 - 180
|
||||||
|
lat := float64(g[1]-'A')*10 - 90
|
||||||
|
lon += float64(g[2]-'0') * 2
|
||||||
|
lat += float64(g[3]-'0') * 1
|
||||||
|
if len(g) >= 6 {
|
||||||
|
lon += float64(g[4]-'A') * (2.0 / 24)
|
||||||
|
lat += float64(g[5]-'A') * (1.0 / 24)
|
||||||
|
lon += (2.0 / 24) / 2
|
||||||
|
lat += (1.0 / 24) / 2
|
||||||
|
} else {
|
||||||
|
lon += 1
|
||||||
|
lat += 0.5
|
||||||
|
}
|
||||||
|
return lat, lon, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// numericRate is the range rate measured rather than reported: the distance a
|
||||||
|
// second later minus the distance a second earlier, over two seconds. It cannot
|
||||||
|
// disagree with physics, so it is the reference the library's own figure is
|
||||||
|
// checked against.
|
||||||
|
func numericRate(el sat.Element, obs sat.Observer, at time.Time) float64 {
|
||||||
|
a, e1 := el.Track(obs, at.Add(-time.Second))
|
||||||
|
b, e2 := el.Track(obs, at.Add(time.Second))
|
||||||
|
if e1 != nil || e2 != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (b.RangeKm - a.RangeKm) / 2
|
||||||
|
}
|
||||||
@@ -584,6 +584,11 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(string) error
|
SetTXAntenna(string) error
|
||||||
|
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
|
||||||
|
// different bands and, with transverters, two different ports.
|
||||||
|
SatAntennas(rxAnt, txAnt string) error
|
||||||
|
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
||||||
|
SatTone(hz float64) error
|
||||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||||
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
|
// ZoomPan sets the visible width (MHz) of the active slice's panadapter and
|
||||||
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
||||||
|
|||||||
+78
-2
@@ -161,8 +161,11 @@ func (f *Flex) satMode(idx int, mode string, freqHz int64) {
|
|||||||
if mode == "" {
|
if mode == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// USB on both sides above 30 MHz, which is every satellite worth the name —
|
// A bare "SSB" still means upper sideband above 30 MHz, which is every
|
||||||
// including the parts of a passband that would be an LSB band down on HF.
|
// satellite worth the name — including the parts of a passband that would be
|
||||||
|
// an LSB band down on HF. An explicit USB or LSB from the caller is left
|
||||||
|
// alone: on an INVERTING transponder the two sides are different sidebands,
|
||||||
|
// and only the caller knows which way round this bird runs.
|
||||||
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
if strings.EqualFold(mode, "SSB") && freqHz > 30_000_000 {
|
||||||
mode = "USB"
|
mode = "USB"
|
||||||
}
|
}
|
||||||
@@ -200,3 +203,76 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
|||||||
}
|
}
|
||||||
return s.freqHz, nil
|
return s.freqHz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SatAntennas selects the antenna each satellite slice uses.
|
||||||
|
//
|
||||||
|
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
||||||
|
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
||||||
|
// antenna setting. On a station with transverters they are not even the same
|
||||||
|
// port: XVTA for 2 m, XVTB for 70 cm, and a downlink slice left on the HF
|
||||||
|
// antenna hears nothing at all.
|
||||||
|
//
|
||||||
|
// Per SLICE, not through sendSlice, which addresses whichever slice is active.
|
||||||
|
// During a pass the active slice is the downlink, so the uplink's antenna would
|
||||||
|
// never have been set.
|
||||||
|
//
|
||||||
|
// Empty strings are left alone: an operator who has configured 2 m and not
|
||||||
|
// 70 cm should keep whatever the radio already had on the other side rather
|
||||||
|
// than have it cleared.
|
||||||
|
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
rx, tx := f.satRX, f.satTX
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
// The downlink slice is the one being listened to, so it takes the receive
|
||||||
|
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
||||||
|
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
||||||
|
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
||||||
|
}
|
||||||
|
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
||||||
|
// A transmit slice also has to HEAR its own band on some radios, and a
|
||||||
|
// transverter port is the only thing connected to it. Setting the
|
||||||
|
// receive antenna to match costs nothing when it is already right.
|
||||||
|
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
||||||
|
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SatTone sets the CTCSS tone the uplink slice transmits, in Hz. Zero turns it
|
||||||
|
// off.
|
||||||
|
//
|
||||||
|
// On the UPLINK slice, because that is the one that keys: a tone is something
|
||||||
|
// transmitted, and the repeater on the satellite will not open without it. This
|
||||||
|
// is the whole difference between an operator hearing a pass and hearing
|
||||||
|
// nothing on SO-50, AO-91, PO-101 and every other FM bird with a tone — and it
|
||||||
|
// is exactly the setting that cannot be made by hand mid-pass.
|
||||||
|
func (f *Flex) SatTone(hz float64) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
tx := f.satTX
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if tx < 0 {
|
||||||
|
return nil // the slice has not come back yet; the next arming will set it
|
||||||
|
}
|
||||||
|
if hz <= 0 {
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=OFF", tx))
|
||||||
|
applog.Printf("flex: satellite uplink tone off")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Value before mode: a radio that is told CTCSS_TX while still holding the
|
||||||
|
// previous tone transmits the previous tone for as long as it takes the
|
||||||
|
// second command to arrive.
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_value=%.1f", tx, hz))
|
||||||
|
f.send(fmt.Sprintf("slice s %d fm_tone_mode=CTCSS_TX", tx))
|
||||||
|
applog.Printf("flex: satellite uplink tone %.1f Hz on slice %d", hz, tx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+37
-4
@@ -271,9 +271,6 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
|
return Position{}, fmt.Errorf("sat: %q: %w", e.Name, err)
|
||||||
}
|
}
|
||||||
// The state vector carries the position AND the velocity, which is what the
|
|
||||||
// look angle needs for the range rate — and the range rate is the whole of
|
|
||||||
// the Doppler shift.
|
|
||||||
sv := &sgp4.StateVector{
|
sv := &sgp4.StateVector{
|
||||||
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
|
X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z,
|
||||||
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
|
VX: eci.Velocity.X, VY: eci.Velocity.Y, VZ: eci.Velocity.Z,
|
||||||
@@ -292,10 +289,46 @@ func (e Element) Track(obs Observer, at time.Time) (Position, error) {
|
|||||||
Az: o.LookAngles.Azimuth,
|
Az: o.LookAngles.Azimuth,
|
||||||
El: o.LookAngles.Elevation,
|
El: o.LookAngles.Elevation,
|
||||||
RangeKm: o.LookAngles.Range,
|
RangeKm: o.LookAngles.Range,
|
||||||
RangeRate: o.LookAngles.RangeRate,
|
RangeRate: e.rangeRate(loc, at.UTC()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rangeRate is how fast the satellite is closing or opening, in km/s.
|
||||||
|
//
|
||||||
|
// MEASURED, not taken from the propagator. The library reports a range rate
|
||||||
|
// that is wrong by a factor of some 250 AND has the wrong sign — the ISS at
|
||||||
|
// −5.5 km/s (closing) came back as +2036 km/s — which put the Doppler
|
||||||
|
// correction hundreds of kilohertz out and moved it the wrong way. The
|
||||||
|
// difference between two ranges a second apart cannot be wrong in either
|
||||||
|
// respect: it differentiates the very number the panel displays.
|
||||||
|
//
|
||||||
|
// Two extra propagations per call. SGP4 costs microseconds and this runs at
|
||||||
|
// most a few hundred times a second across every satellite on screen, so the
|
||||||
|
// price of being right here is not worth optimising away.
|
||||||
|
func (e Element) rangeRate(loc *sgp4.Location, at time.Time) float64 {
|
||||||
|
const dt = time.Second // ±1 s: far below any curvature in the range, far above float noise
|
||||||
|
before, ok1 := e.rangeAt(loc, at.Add(-dt))
|
||||||
|
after, ok2 := e.rangeAt(loc, at.Add(dt))
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (after - before) / (2 * dt.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangeAt is the distance to the satellite at one instant, in km.
|
||||||
|
func (e Element) rangeAt(loc *sgp4.Location, at time.Time) (float64, bool) {
|
||||||
|
eci, err := e.tle.FindPositionAtTime(at.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
sv := &sgp4.StateVector{X: eci.Position.X, Y: eci.Position.Y, Z: eci.Position.Z}
|
||||||
|
o, err := sv.GetLookAngle(loc, at.UTC())
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return o.LookAngles.Range, true
|
||||||
|
}
|
||||||
|
|
||||||
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
|
// earthRadiusKm is the mean radius — the footprint is a circle drawn on a
|
||||||
// sphere, and a metre of flattening does not show at that scale.
|
// sphere, and a metre of flattening does not show at that scale.
|
||||||
const earthRadiusKm = 6371.0
|
const earthRadiusKm = 6371.0
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/akhenakh/sgp4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A real ISS element set, and the answers a second tracker agrees with. The
|
// A real ISS element set, and the answers a second tracker agrees with. The
|
||||||
@@ -16,6 +18,9 @@ const (
|
|||||||
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
|
issLine2 = "2 25544 51.6392 121.4587 0007976 86.1587 27.9639 15.50126585478227"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testLoc is the same observer, in the form the internal range helper takes.
|
||||||
|
var testLoc = sgp4.Location{Latitude: 48.5, Longitude: 3.0}
|
||||||
|
|
||||||
func issElement(t *testing.T) Element {
|
func issElement(t *testing.T) Element {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
e, err := ParseElement(issName, issLine1, issLine2)
|
e, err := ParseElement(issName, issLine1, issLine2)
|
||||||
@@ -170,3 +175,71 @@ func TestStoreReplaceKeepsOrderAndStampsTheFetch(t *testing.T) {
|
|||||||
t.Error("an unknown satellite was tracked anyway")
|
t.Error("an unknown satellite was tracked anyway")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The range rate is the whole of the Doppler shift, and it was wrong in both
|
||||||
|
// magnitude and sign — the propagator library reported +2036 km/s for an ISS
|
||||||
|
// that was closing at 5.5, which moved the correction hundreds of kilohertz the
|
||||||
|
// wrong way. These are the two things about it that cannot be argued with.
|
||||||
|
func TestRangeRateIsPhysical(t *testing.T) {
|
||||||
|
e := issElement(t)
|
||||||
|
obs := Observer{Lat: 48.5, Lon: 3.0}
|
||||||
|
// A day's worth, sampled across every geometry a pass goes through.
|
||||||
|
base := e.Epoch.Add(2 * time.Hour)
|
||||||
|
for i := 0; i < 240; i++ {
|
||||||
|
at := base.Add(time.Duration(i) * 6 * time.Minute)
|
||||||
|
p, err := e.Track(obs, at)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("track: %v", err)
|
||||||
|
}
|
||||||
|
// Nothing in low earth orbit closes faster than it flies, and it flies
|
||||||
|
// at about 7.7 km/s. A figure outside this is a units mistake.
|
||||||
|
if math.Abs(p.RangeRate) > 8 {
|
||||||
|
t.Fatalf("%s: range rate %.1f km/s — faster than orbital velocity", at.Format(time.RFC3339), p.RangeRate)
|
||||||
|
}
|
||||||
|
// And it must be the derivative of the range we display, sign included.
|
||||||
|
before, _ := e.rangeAt(&testLoc, at.Add(-2*time.Second))
|
||||||
|
after, _ := e.rangeAt(&testLoc, at.Add(2*time.Second))
|
||||||
|
want := (after - before) / 4
|
||||||
|
if math.Abs(p.RangeRate-want) > 0.05 {
|
||||||
|
t.Errorf("%s: range rate %.3f but the range moves at %.3f km/s",
|
||||||
|
at.Format(time.RFC3339), p.RangeRate, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Doppler that comes out of it, on the two bands satellites are worked on.
|
||||||
|
// A LEO gives about ±3.5 kHz on 2 m and ±10 kHz on 70 cm; ten times either is
|
||||||
|
// the bug this pins.
|
||||||
|
func TestDopplerStaysWithinTheTextbookRange(t *testing.T) {
|
||||||
|
e := issElement(t)
|
||||||
|
obs := Observer{Lat: 48.5, Lon: 3.0}
|
||||||
|
base := e.Epoch.Add(2 * time.Hour)
|
||||||
|
var maxVHF, maxUHF int64
|
||||||
|
for i := 0; i < 480; i++ {
|
||||||
|
p, err := e.Track(obs, base.Add(time.Duration(i)*3*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vhf := Doppler(p, 145_800_000, 0).DownHz - 145_800_000
|
||||||
|
uhf := Doppler(p, 437_800_000, 0).DownHz - 437_800_000
|
||||||
|
if a := abs64(vhf); a > maxVHF {
|
||||||
|
maxVHF = a
|
||||||
|
}
|
||||||
|
if a := abs64(uhf); a > maxUHF {
|
||||||
|
maxUHF = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxVHF < 1_500 || maxVHF > 5_000 {
|
||||||
|
t.Errorf("2 m Doppler peaks at %d Hz, expected roughly 3.5 kHz", maxVHF)
|
||||||
|
}
|
||||||
|
if maxUHF < 5_000 || maxUHF > 14_000 {
|
||||||
|
t.Errorf("70 cm Doppler peaks at %d Hz, expected roughly 10 kHz", maxUHF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user