feat(sat): set it up in Settings, work the pass in the tab

Two things belong in different places, and they were in one.

Settings → Satellites now holds the setup: which satellites to follow —
the same two-column shape as the awards, for the same reason, since a
feed carries two hundred birds and an operator works six — and the
orbital elements, their age, the fetch, and pasting your own. Following
none still means every satellite with both elements and a frequency plan,
so somebody who has not chosen yet is not handed an empty tab.

The panel keeps only what a pass needs. A countdown to AOS, or to LOS
once it is up, because that is the number that decides whether you sit
down; a bar for where in the pass you are, since mid-pass the useful
question is not the clock but whether you are past the peak; rise, peak
and set with compass directions, because "rises SW" is a direction to
look in and 213° is arithmetic. Distance, altitude and footprint. And
approaching or receding, which is the sign of the whole Doppler
correction and the only thing that explains why the frequencies are
moving the way they are.

The countdowns run in the browser from two timestamps. Predicting a pass
steps the orbit across a day thirty seconds at a time, which is not
something to do once a second for a clock the page can keep itself.
This commit is contained in:
2026-09-07 15:34:26 +02:00
parent 7f03b046ab
commit 3ed48336af
8 changed files with 557 additions and 122 deletions
+76
View File
@@ -136,6 +136,37 @@ type SatTuning struct {
RangeRate float64 `json:"range_rate"`
Visible bool `json:"visible"`
At time.Time `json:"at"`
// Where the satellite is over the earth. Carried with the tuning because
// they are read together and change together — the panel would otherwise ask
// twice a second for two halves of one instant.
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
AltKm float64 `json:"alt_km"`
Footprint float64 `json:"footprint_km"`
}
// SatPassInfo is the pass in progress, or the next one.
//
// Separate from the tuning and polled far more slowly: predicting a pass steps
// the orbit thirty seconds at a time across hours, which is not something to do
// once a second for a countdown a browser can run itself from two timestamps.
type SatPassInfo struct {
Name string `json:"name"`
HasPass bool `json:"has_pass"`
// InPass distinguishes "it is up now" from "it rises at". The pass in
// progress is reported whatever its maximum elevation: an operator watching
// a satellite go over does not want it hidden because it fell below the
// threshold that filters the TABLE of what is worth waiting for.
InPass bool `json:"in_pass"`
AOS time.Time `json:"aos"`
LOS time.Time `json:"los"`
AOSAz float64 `json:"aos_az"`
LOSAz float64 `json:"los_az"`
MaxEl float64 `json:"max_el"`
MaxElAz float64 `json:"max_el_az"`
MaxElAt time.Time `json:"max_el_at"`
Duration float64 `json:"duration_s"`
}
// ── Lifecycle ───────────────────────────────────────────────────────────────
@@ -708,6 +739,50 @@ func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error)
return passes, nil
}
// GetSatelliteNextPass is the pass in progress, or the next one to come.
//
// The one question that decides whether an operator sits down at the radio, and
// the reason a satellite tab is worth having at all: how long have I got, and
// how high does it get.
func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) {
out := SatPassInfo{Name: name}
obs, err := a.satObserver()
if err != nil {
return out, err
}
real, ok := a.satResolve(name)
if !ok {
return out, fmt.Errorf("%s is not in the element set", name)
}
store, _, _ := a.satParts()
now := time.Now().UTC()
// From a little before now: a pass that started two minutes ago is the one
// the operator is in, and asking from this instant would skip it and report
// the next orbit instead — an hour and a half away, while the satellite is
// overhead.
from := now.Add(-30 * time.Minute)
// Elevation zero, not the operator's minimum. That threshold filters the
// table of passes worth waiting for; it must not hide the pass they are
// actually working.
passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0)
if err != nil {
return out, err
}
for _, p := range passes {
if p.LOS.Before(now) {
continue // already over
}
out.HasPass = true
out.InPass = !p.AOS.After(now)
out.AOS, out.LOS = p.AOS, p.LOS
out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz
out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt
out.Duration = p.Duration
return out, nil
}
return out, nil
}
// GetSatelliteTuning is the working answer: where to listen, where to transmit,
// and where the bird is, for one satellite and one transponder.
//
@@ -777,6 +852,7 @@ func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (Sa
sh := sat.Doppler(p, out.NominalDown, out.NominalUp)
out.DownHz, out.UpHz = sh.DownHz, sh.UpHz
out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate
out.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint
out.Visible = p.Visible()
return out, nil
}