package main // Doppler tracking — walking the radio through a pass. // // The hard part of satellite tuning is not the arithmetic, it is deciding who // owns the dial. A tracker that simply forces both frequencies fights the // operator every time they turn the knob to follow a station across a linear // transponder, and one that never touches the receiver leaves them chasing a // signal that slides 9 kHz across a 70 cm pass. // // So: the operator owns the receiver, and the tracker follows them. Every tick // it asks the radio where the receiver actually is. If that is where the tracker // put it, nothing has changed and it keeps correcting from the same NOMINAL // frequency. If it has moved further than a dial-turn's tolerance, the operator // has chosen a new station: the tracker converts what they landed on back into a // nominal frequency and carries on from there. The transmitter is derived from // the nominal and never argued with — which is exactly the division of labour on // a linear bird, where the operator listens and the radio does the sums. import ( "fmt" "math" "strings" "sync" "time" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" "hamlog/internal/applog" "hamlog/internal/cat" "hamlog/internal/qso" "hamlog/internal/sat" ) // satTickEvery is how often the radio is re-pointed. One second: at the middle // of a 70 cm pass the downlink moves about 60 Hz a second, which is audible on // SSB within two or three of them and inaudible within one. const satTickEvery = time.Second // satDialTolerance is how far the receiver may differ from where the tracker put // it before that difference is read as the operator tuning. // // 200 Hz is comfortably more than the rounding and the round-trip lag between // setting a frequency and reading it back, and comfortably less than the // smallest deliberate move anybody makes hunting a station on a transponder. const satDialTolerance = 200 // satLightKmS is the speed of light in km/s, for turning a heard frequency back // into a nominal one. The same constant internal/sat corrects with. const satLightKmS = 299792.458 type satTracker struct { name string tp int mu sync.Mutex // nominalDown is where the operator is, expressed as if the satellite were // standing still. Everything else is derived from it, and it is the only // thing a dial movement changes. nominalDown int64 lastDown int64 // what was last sent to the radio lastUp int64 status SatTrackStatus fails int stop chan struct{} done chan struct{} } // SatTrackStatus is what the tracker is doing, for the panel. type SatTrackStatus struct { On bool `json:"on"` Name string `json:"name"` Transponder string `json:"transponder"` Mode string `json:"mode"` NominalDown int64 `json:"nominal_down"` NominalUp int64 `json:"nominal_up"` DownHz int64 `json:"down_hz"` UpHz int64 `json:"up_hz"` Az float64 `json:"az"` El float64 `json:"el"` Visible bool `json:"visible"` Radio string `json:"radio"` // what the rig is doing: "sat", "downlink-only", "" Error string `json:"error"` } // StartSatelliteTracking arms the radio and starts following the satellite. func (a *App) StartSatelliteTracking(name string, transponder int) error { if a.cat == nil { return fmt.Errorf("CAT is not running") } _, birds, _ := a.satParts() b, ok := birds.Find(name) if !ok || len(b.Transponders) == 0 { return fmt.Errorf("%s has no frequency plan to tune to", name) } if transponder < 0 || transponder >= len(b.Transponders) { transponder = 0 } a.StopSatelliteTracking() t := &satTracker{ name: b.Name, tp: transponder, nominalDown: b.Transponders[transponder].Centre(), stop: make(chan struct{}), done: make(chan struct{}), } t.status = SatTrackStatus{On: true, Name: b.Name, Transponder: b.Transponders[transponder].Label, Mode: b.Transponders[transponder].Mode} // Arm the radio for the pair. A rig that cannot hold one is NOT a failure: // it can still be tuned to the downlink, which is most of a receive-heavy // pass, and saying so beats refusing to track at all. radio := "downlink-only" if a.cat.SatCapable() { if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(true) }); err != nil { applog.Printf("sat: could not arm satellite mode: %v", err) t.status.Error = err.Error() } else { radio = "sat" } } t.status.Radio = radio a.satTrackMu.Lock() a.satTrack = t a.satTrackMu.Unlock() go a.satTrackLoop(t) applog.Printf("sat: tracking %s (%s), radio %s", t.name, t.status.Transponder, radio) return nil } // StopSatelliteTracking hands the radio back. func (a *App) StopSatelliteTracking() { a.satTrackMu.Lock() t := a.satTrack a.satTrack = nil a.satTrackMu.Unlock() if t == nil { return } close(t.stop) <-t.done if a.cat != nil && a.cat.SatCapable() { if err := a.cat.SatDo(func(st cat.SatTuner) error { return st.SetSatellite(false) }); err != nil { applog.Printf("sat: could not disarm satellite mode: %v", err) } } applog.Printf("sat: tracking stopped (%s)", t.name) a.emitSatTrack(SatTrackStatus{}) } // GetSatelliteTracking reports what the tracker is doing. func (a *App) GetSatelliteTracking() SatTrackStatus { a.satTrackMu.Lock() t := a.satTrack a.satTrackMu.Unlock() if t == nil { return SatTrackStatus{} } t.mu.Lock() defer t.mu.Unlock() return t.status } // satTrackedNominal is the nominal downlink the tracker is currently working // from, or 0 when it is not tracking this satellite and transponder. func (a *App) satTrackedNominal(name string, transponder int) int64 { a.satTrackMu.Lock() t := a.satTrack a.satTrackMu.Unlock() if t == nil || t.tp != transponder || !strings.EqualFold(t.name, name) { return 0 } t.mu.Lock() defer t.mu.Unlock() return t.nominalDown } func (a *App) emitSatTrack(s SatTrackStatus) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "sat:track", s) } } func (a *App) satTrackLoop(t *satTracker) { defer close(t.done) tick := time.NewTicker(satTickEvery) defer tick.Stop() for { a.satTrackStep(t) select { case <-t.stop: return case <-tick.C: } } } // satTrackStep is one pass of the loop: read the dial, work out the pair, send // what changed. func (a *App) satTrackStep(t *satTracker) { _, birds, _ := a.satParts() b, ok := birds.Find(t.name) if !ok || t.tp >= len(b.Transponders) { return } tp := b.Transponders[t.tp] t.mu.Lock() nominal := t.nominalDown lastDown, lastUp := t.lastDown, t.lastUp t.mu.Unlock() // Where the satellite is, and how fast it is running away. A geostationary // bird is neither: its range rate is zero, so the zero position below gives // a zero shift without a special case, and asking for a look angle we do not // need would only fail on a station with no locator. var pos sat.Position visible := true if !b.Geostationary { obs, err := a.satObserver() if err != nil { t.setError(err.Error()) return } real, ok := a.satResolve(t.name) if !ok { t.setError(fmt.Sprintf("%s is not in the element set", t.name)) return } store, _, _ := a.satParts() p, err := store.Track(real, obs, time.Now().UTC()) if err != nil { t.setError(err.Error()) return } pos = p visible = p.Visible() } // The fractional shift, positive when the satellite is approaching. Only the // dial arithmetic below needs it as a number; the pair itself comes from // sat.Doppler, so there is exactly one place where the sign of a correction // is decided. factor := -pos.RangeRate / satLightKmS // Where did the operator leave the receiver? If it is not where the tracker // put it, they have moved to another station and that is the new nominal. if lastDown > 0 && tp.Linear() { if actual, err := a.satReceiveHz(); err == nil && actual > 0 { if abs64i(actual-lastDown) > satDialTolerance { moved := satNominalFromDial(actual, factor) if moved >= tp.DownLo && moved <= tp.DownHi { nominal = moved t.mu.Lock() t.nominalDown = moved t.mu.Unlock() } } } } nomUp := tp.UplinkFor(nominal) sh := sat.Doppler(pos, nominal, nomUp) down, up := sh.DownHz, sh.UpHz t.mu.Lock() t.status = SatTrackStatus{ On: true, Name: b.Name, Transponder: tp.Label, Mode: tp.Mode, NominalDown: nominal, NominalUp: nomUp, DownHz: down, UpHz: up, Az: pos.Az, El: pos.El, Visible: visible, Radio: t.status.Radio, Error: t.status.Error, } t.mu.Unlock() a.emitSatTrack(t.status) // Only send what has actually moved. The step is the smallest change worth a // command: on SSB a listener hears twenty hertz, on an FM channel nothing // under a couple of hundred matters at all. step := int64(20) if strings.EqualFold(tp.Mode, "FM") { step = 200 } if abs64i(down-lastDown) < step && abs64i(up-lastUp) < step { return } mode := tp.Mode if lastDown != 0 { mode = "" // set once, at the start of the pass — see satMode/satSetMode } err := a.satTune(down, up, mode, mode) t.mu.Lock() if err == nil { t.lastDown, t.lastUp, t.fails = down, up, 0 t.status.Error = "" } else { t.fails++ t.status.Error = err.Error() } fails := t.fails t.mu.Unlock() if err != nil && (fails == 1 || fails%30 == 0) { // Once, then once every half minute: a radio that has gone away must be // visible in the log without filling it. applog.Printf("sat: tuning %s failed (%d in a row): %v", t.name, fails, err) } } // satNominalFromDial turns a frequency the operator tuned to into the nominal // one it corresponds to. // // The inverse of the downlink correction: what comes out of the transponder at // nominal arrives at heard = nominal × (1 + f). Doing this is what lets the // operator hunt across a linear passband without the tracker dragging them back // — where they land becomes the new truth, and the uplink follows it. func satNominalFromDial(heardHz int64, factor float64) int64 { if heardHz <= 0 || factor <= -1 { return heardHz } return int64(math.Round(float64(heardHz) / (1 + factor))) } func (t *satTracker) setError(msg string) { t.mu.Lock() t.status.Error = msg t.mu.Unlock() } // satTune sends the pair to whichever radio is connected. func (a *App) satTune(downHz, upHz int64, downMode, upMode string) error { if a.cat == nil { return fmt.Errorf("CAT is not running") } if a.cat.SatCapable() { return a.cat.SatDo(func(st cat.SatTuner) error { return st.TuneSatellite(downHz, upHz, downMode, upMode) }) } // No satellite pair on this backend: the downlink is what it can do, and the // operator was told so when tracking started (Radio = "downlink-only"). if err := a.cat.SetFrequency(downHz); err != nil { return err } if downMode != "" { return a.cat.SetMode(downMode) } return nil } // satReceiveHz is where the receiver is, asked of the backend that knows. func (a *App) satReceiveHz() (int64, error) { if a.cat == nil { return 0, fmt.Errorf("CAT is not running") } if a.cat.SatCapable() { var hz int64 err := a.cat.SatDo(func(st cat.SatTuner) error { v, e := st.SatReceiveHz() hz = v return e }) return hz, err } st := a.cat.State() if st.RxFreqHz > 0 { return st.RxFreqHz, nil } return st.FreqHz, nil } func abs64i(v int64) int64 { if v < 0 { return -v } return v } // ── What goes in the log ──────────────────────────────────────────────────── // applySatellite stamps a QSO made through a satellite. // // The NOMINAL frequencies are logged, never the Doppler-corrected ones. Two // stations working each other through a transponder read different numbers off // their dials at the same instant — that is what Doppler means — and the only // figure they can both agree on, and the only one that means anything to // somebody reading the log later, is the transponder's own. LoTW matches on the // band, so nothing is lost; a log full of 435.847 231 would simply be a record // of where one radio happened to be. func (a *App) applySatellite(q *qso.QSO) { a.satTrackMu.Lock() t := a.satTrack a.satTrackMu.Unlock() if t == nil { return } t.mu.Lock() name, down, up := t.status.Name, t.status.NominalDown, t.status.NominalUp az, el := t.status.Az, t.status.El t.mu.Unlock() if name == "" || down <= 0 { return } // Nothing the operator filled in is overwritten. A QSO edited by hand, or // imported, or logged from a second radio while the tracker happened to be // running, keeps what it was given. if strings.TrimSpace(q.PropMode) == "" { q.PropMode = "SAT" } if q.PropMode != "SAT" { return // they said it was something else — meteor scatter, EME } if strings.TrimSpace(q.SatName) == "" { q.SatName = name } if strings.TrimSpace(q.SatMode) == "" { q.SatMode = satModeLetters(up, down) } // The transmit frequency is the uplink and the receive frequency the // downlink — which is the one place a satellite QSO differs from every other // kind, and the reason FREQ alone cannot describe one. if up > 0 { q.FreqHz = &up if b := bandForHz(up); b != "" { q.Band = b } } d := down q.FreqRXHz = &d if b := bandForHz(down); b != "" { q.BandRX = b } if q.AntAz == nil && (az != 0 || el != 0) { v := az q.AntAz = &v } if q.AntEl == nil && el != 0 { v := el q.AntEl = &v } } // satModeLetters is the ADIF SAT_MODE: the uplink band's letter, then the // downlink's — "U/V" for 435 up, 145 down. The letters are AMSAT's, and they // are what every satellite operator writes on a QSL card. func satModeLetters(upHz, downHz int64) string { u, d := satBandLetter(upHz), satBandLetter(downHz) if u == "" || d == "" { return "" } return u + "/" + d } func satBandLetter(hz int64) string { switch { case hz <= 0: return "" case hz < 30_000_000: return "A" // 10 m — mode A's downlink case hz < 148_000_000: return "V" // 2 m case hz < 450_000_000: return "U" // 70 cm case hz < 1_300_000_000: return "L" // 23 cm case hz < 2_500_000_000: return "S" // 13 cm case hz < 6_000_000_000: return "C" // 6 cm case hz < 11_000_000_000: return "X" // 3 cm } return "K" // 24 GHz and above }