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