fix(sat): the Doppler correction was 250 times too big, and backwards
Reported as "the Doppler moves the frequency enormously", and it did: a 2 m
downlink was being shifted two megahertz across a pass instead of three
kilohertz, in the wrong direction.
The propagator library reports a range rate that is not one. Measured against
the range it is meant to be the derivative of, on this station's own cached
elements:
PO-101 library -1457.3 km/s measured +6.227 km/s
ISS library +2036.8 km/s measured -5.522 km/s
Wrong by a factor of some 250 and of the wrong sign, so the correction both
overshot and pushed the operator away from the station they could hear. Nothing
else was affected — the elevation and the passes come from the look angle, which
is right — which is why this survived: the satellite was in the correct place on
the map while the radio was told to go megahertz away from it.
So OpsLog computes it itself, as the difference between two ranges a second
apart. That cannot be wrong in either magnitude or sign: it differentiates the
very number the panel displays. Two extra propagations per call, which is
microseconds.
Two tests pin it, and both fail against the old behaviour: a range rate faster
than orbital velocity is a units mistake, and the Doppler on the two bands
satellites are worked on has a textbook size — about ±3.5 kHz on 2 m, ±10 kHz on
70 cm.
cmd/satdiag is the throwaway that found it, kept because the next report of this
shape ("the frequency moves oddly", "that pass is not real") is answered by the
same three numbers: which elements the satellite resolved to, the range rate
reported against the range rate measured, and the Doppler each transponder gets.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user