feat(sat): generate the frequency plan, and join on the catalog number
25 satellites, typed by hand and never revisited. Eight of them were the
first-generation Tevel constellation, which re-entered in 2024; four more had
come down too; and the nine Tevel-2 satellites that replaced them, the Chinese
space station, AO-27, AO-123 and twenty others were simply absent. So: 44
satellites now, and a generator instead of a memory.
cmd/satgen joins three public sources on the NORAD catalog number — Celestrak's
amateur group and PE0SAT's mirror for which birds OpsLog can actually get
elements for, and SatNOGS DB for the transmitters. It is a one-shot tool, run by
hand, the same arrangement as cmd/cntygen, and it is deliberately conservative:
- It never destroys a curated entry. The hand-written plans hold things
SatNOGS does not reliably carry — a CTCSS tone, the QO-100 passband as
operators describe it — so an existing bird keeps its data and only gains
its catalog number.
- It prunes on the re-entry date, which is a fact SatNOGS publishes rather
than a judgement about which of the missing satellites are missing for good.
- It refuses a digital uplink that does not say what it is. A GMSK uplink is a
command channel far more often than a digipeater, and shipping the wrong one
invites somebody to transmit on a control frequency. An analog uplink with
both ends is a contact by construction, which is what catches the repeaters
that describe themselves only as "Mode V/U FM".
- Its output is deterministic. One satellite can hold two catalog entries —
GreenCube is 53106 in one feed and 53109 in the other — and iterating a map
picked a different one each run.
A bird now carries its NORAD number, and that is how its elements are found.
Names were the only join before, and they are written differently by every party
involved: "TIANYAN 01" and "TO-108" are one satellite that had never once met,
so TO-108 tracked nothing at all.
And the plan now reaches a station that has already run OpsLog. The editable
copy was written on the first launch and was the operator's list for ever after,
so a release adding nine satellites reached nobody who had opened the tab. It is
merged on each load instead: a satellite they already have is untouched, edits
and corrections included, and only the ones they have never seen are added.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -622,6 +622,11 @@ func (a *App) GetSatelliteNames() []string {
|
|||||||
// The feed's name and the operator's name for the same satellite are routinely
|
// The feed's name and the operator's name for the same satellite are routinely
|
||||||
// different, and the element set is keyed by the feed's.
|
// different, and the element set is keyed by the feed's.
|
||||||
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
func satElement(store *sat.Store, b sat.Bird) (sat.Element, bool) {
|
||||||
|
// The catalog number first: it is exact, and it is what the generated plan
|
||||||
|
// carries. Everything below is for the hand-written entries that have none.
|
||||||
|
if e, ok := store.GetNORAD(b.NORAD); ok {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
if e, ok := store.Get(b.Name); ok {
|
if e, ok := store.Get(b.Name); ok {
|
||||||
return e, true
|
return e, true
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -9,7 +9,9 @@
|
|||||||
"EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass.",
|
"EasyComm II is now an ordinary rotator interface, so it can turn the antenna from the compass and from a spot click, not only during a satellite pass.",
|
||||||
"On the satellite map, an unselected satellite is readable: a bigger dot with a dark halo under a white ring, which shows up on a street map and on a dark ocean alike, and the ones above the horizon carry their name.",
|
"On the satellite map, an unselected satellite is readable: a bigger dot with a dark halo under a white ring, which shows up on a street map and on a dark ocean alike, and the ones above the horizon carry their name.",
|
||||||
"Hovering a satellite on the map now says what a pass is worth — elevation and azimuth, distance and whether it is closing or going away, rise and set with the countdown, and how high it will get. It no longer closes itself every five seconds while you read it.",
|
"Hovering a satellite on the map now says what a pass is worth — elevation and azimuth, distance and whether it is closing or going away, rise and set with the countdown, and how high it will get. It no longer closes itself every five seconds while you read it.",
|
||||||
"The satellite footprint is drawn for the selected bird only. A footprint is thousands of kilometres across, and a dozen of them overlapped into a wash of circles that hid the coastline, the ground track and the satellites themselves."
|
"The satellite footprint is drawn for the selected bird only. A footprint is thousands of kilometres across, and a dozen of them overlapped into a wash of circles that hid the coastline, the ground track and the satellites themselves.",
|
||||||
|
"The frequency plan goes from 25 satellites to 44, cut from Celestrak, PE0SAT and the SatNOGS transponder database instead of typed by hand — the nine Tevel-2 satellites, the Chinese space station, AO-27, AO-123, RS-44 and twenty more. Twelve that had re-entered are gone, first-generation Tevel among them. Your own file is merged rather than replaced: satellites you have never seen are added, and any frequency you corrected stands.",
|
||||||
|
"A satellite is now found by its catalog number rather than by its name. \"RADFXSAT (FOX-1B)\" and \"AO-91\" are the same bird, and so are \"TIANYAN 01\" and \"TO-108\" — the second pair never met before, so TO-108 tracked nothing."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait qu’en choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.",
|
"Toutes les interfaces de rotor sont désormais dans Réglages ▸ Rotator, et la page satellite ne fait qu’en choisir une. EasyComm et PstRotator se configuraient dans les réglages satellite pendant que les autres se configuraient dans la liste des rotors : un même pylône était décrit deux fois. Ce que vous aviez réglé est déplacé dans la liste et sélectionné automatiquement.",
|
||||||
@@ -18,7 +20,9 @@
|
|||||||
"EasyComm II devient une interface de rotor comme les autres : elle peut tourner l’antenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite.",
|
"EasyComm II devient une interface de rotor comme les autres : elle peut tourner l’antenne depuis le compas et depuis un clic sur un spot, plus seulement pendant un passage satellite.",
|
||||||
"Sur la carte satellite, un satellite non sélectionné est lisible : un point plus gros avec un halo sombre sous un anneau blanc, visible aussi bien sur une carte routière que sur un océan noir, et ceux au-dessus de l’horizon portent leur nom.",
|
"Sur la carte satellite, un satellite non sélectionné est lisible : un point plus gros avec un halo sombre sous un anneau blanc, visible aussi bien sur une carte routière que sur un océan noir, et ceux au-dessus de l’horizon portent leur nom.",
|
||||||
"Le survol d’un satellite sur la carte indique désormais ce que vaut le passage — élévation et azimut, distance et si elle diminue ou augmente, lever et coucher avec le décompte, et la hauteur qu’il atteindra. L’infobulle ne se referme plus toutes les cinq secondes pendant qu’on la lit.",
|
"Le survol d’un satellite sur la carte indique désormais ce que vaut le passage — élévation et azimut, distance et si elle diminue ou augmente, lever et coucher avec le décompte, et la hauteur qu’il atteindra. L’infobulle ne se referme plus toutes les cinq secondes pendant qu’on la lit.",
|
||||||
"L’empreinte au sol n’est tracée que pour le satellite sélectionné. Une empreinte fait des milliers de kilomètres, et une douzaine se superposaient en un lavis de cercles qui masquait le trait de côte, la trace au sol et les satellites eux-mêmes."
|
"L’empreinte au sol n’est tracée que pour le satellite sélectionné. Une empreinte fait des milliers de kilomètres, et une douzaine se superposaient en un lavis de cercles qui masquait le trait de côte, la trace au sol et les satellites eux-mêmes.",
|
||||||
|
"Le plan de fréquences passe de 25 à 44 satellites, généré depuis Celestrak, PE0SAT et la base de transpondeurs SatNOGS au lieu d’être saisi à la main — les neuf Tevel-2, la station spatiale chinoise, AO-27, AO-123, RS-44 et vingt autres. Douze rentrés dans l’atmosphère ont été retirés, dont les Tevel de première génération. Votre fichier est fusionné et non remplacé : les satellites inconnus sont ajoutés, et vos corrections de fréquence restent.",
|
||||||
|
"Un satellite est désormais trouvé par son numéro de catalogue plutôt que par son nom. « RADFXSAT (FOX-1B) » et « AO-91 » sont le même oiseau, tout comme « TIANYAN 01 » et « TO-108 » — ces deux-là ne se rencontraient jamais, donc TO-108 ne suivait rien."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// Command satgen refreshes internal/sat/birds.json from the public databases.
|
||||||
|
//
|
||||||
|
// A one-shot generator, run by hand, NOT part of the build — the same
|
||||||
|
// arrangement as cmd/cntygen. Satellites are switched between modes and new
|
||||||
|
// ones fly, and the shipped frequency plan should be re-cut every few releases
|
||||||
|
// rather than typed from memory.
|
||||||
|
//
|
||||||
|
// go run ./cmd/satgen
|
||||||
|
//
|
||||||
|
// It reads three sources and joins them on the NORAD catalog number:
|
||||||
|
//
|
||||||
|
// - Celestrak's amateur group and PE0SAT's mirror, for WHICH satellites
|
||||||
|
// OpsLog can get elements for. There is no point shipping a frequency plan
|
||||||
|
// for a bird whose TLE never arrives.
|
||||||
|
// - SatNOGS DB, for the transmitters. It is the maintained, machine-readable
|
||||||
|
// transponder database; AMSAT's chart is authoritative but is a web page.
|
||||||
|
//
|
||||||
|
// It NEVER destroys a curated entry. The hand-written plans carry things
|
||||||
|
// SatNOGS does not reliably hold — a CTCSS tone, a readable label, the QO-100
|
||||||
|
// passband as operators actually describe it — so an existing bird is kept
|
||||||
|
// verbatim and only has its NORAD number filled in. New satellites are appended.
|
||||||
|
// Read the diff before committing it: this is a starting point for an operator,
|
||||||
|
// and a wrong uplink is worse than a missing one.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/sat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
birdsPath = "internal/sat/birds.json"
|
||||||
|
satnogsTX = "https://db.satnogs.org/api/transmitters/?format=json"
|
||||||
|
satnogsSats = "https://db.satnogs.org/api/satellites/?format=json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// satellite is the subset of a SatNOGS satellite record we use. Its whole
|
||||||
|
// purpose is the decay date: a frequency plan for a spacecraft that burned up
|
||||||
|
// two years ago is a row in the operator's list that will never do anything.
|
||||||
|
type satellite struct {
|
||||||
|
NORAD int `json:"norad_cat_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Names string `json:"names"` // other designations, comma or newline separated
|
||||||
|
Status string `json:"status"`
|
||||||
|
Decayed string `json:"decayed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tleFeeds are the element sources OpsLog itself reads (see internal/sat/tle.go).
|
||||||
|
var tleFeeds = []string{
|
||||||
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle",
|
||||||
|
"http://tle.pe0sat.nl/kepler/amateur.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
// transmitter is the subset of a SatNOGS DB record we use.
|
||||||
|
type transmitter struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
Alive bool `json:"alive"`
|
||||||
|
Type string `json:"type"` // Transmitter | Transponder | Transceiver
|
||||||
|
UplinkLow int64 `json:"uplink_low"`
|
||||||
|
UplinkHigh int64 `json:"uplink_high"`
|
||||||
|
DownlinkLow int64 `json:"downlink_low"`
|
||||||
|
DownlinkHigh int64 `json:"downlink_high"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Invert bool `json:"invert"`
|
||||||
|
NORAD int `json:"norad_cat_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
feed, err := loadFeeds()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("elements: %d satellites across %d feeds\n", len(feed), len(tleFeeds))
|
||||||
|
|
||||||
|
txs, err := loadTransmitters()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("satnogs: %d transmitters\n", len(txs))
|
||||||
|
|
||||||
|
cat, err := loadSatellites()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("satnogs: %d catalogued satellites\n", len(cat))
|
||||||
|
|
||||||
|
birds, err := loadBirds()
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("existing plan: %d satellites\n", len(birds))
|
||||||
|
|
||||||
|
// 0. Drop what has come down. SatNOGS carries the re-entry date, so this is
|
||||||
|
// a documented fact rather than a judgement about which of the missing
|
||||||
|
// satellites are missing for good — the first-generation Tevel
|
||||||
|
// constellation alone had left eight rows that could never do anything.
|
||||||
|
kept := birds[:0]
|
||||||
|
for _, b := range birds {
|
||||||
|
if s, ok := decayed(b, cat); ok {
|
||||||
|
fmt.Printf(" - %s re-entered %s — removed\n", b.Name, strings.TrimSuffix(s.Decayed, "T00:00:00Z"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, b)
|
||||||
|
}
|
||||||
|
birds = kept
|
||||||
|
|
||||||
|
// 1. Give every curated entry its catalog number, so the join stops
|
||||||
|
// depending on how three different parties spell the same satellite.
|
||||||
|
covered := map[int]bool{}
|
||||||
|
for i := range birds {
|
||||||
|
if birds[i].NORAD == 0 {
|
||||||
|
if n, ok := noradFor(birds[i], feed); ok {
|
||||||
|
birds[i].NORAD = n
|
||||||
|
fmt.Printf(" + NORAD %5d for %s\n", n, birds[i].Name)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ! no elements found for %s — left without a catalog number\n", birds[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if birds[i].NORAD != 0 {
|
||||||
|
covered[birds[i].NORAD] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Append the satellites we can track and have a usable uplink for.
|
||||||
|
byNORAD := map[int][]transmitter{}
|
||||||
|
for _, t := range txs {
|
||||||
|
if !usable(t) || feed[t.NORAD] == "" || covered[t.NORAD] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byNORAD[t.NORAD] = append(byNORAD[t.NORAD], t)
|
||||||
|
}
|
||||||
|
added := 0
|
||||||
|
for n, list := range byNORAD {
|
||||||
|
b := sat.Bird{Name: displayName(feed[n]), NORAD: n}
|
||||||
|
if alias := strings.TrimSpace(feed[n]); alias != "" && alias != b.Name {
|
||||||
|
b.Aliases = []string{alias}
|
||||||
|
}
|
||||||
|
for _, t := range list {
|
||||||
|
b.Transponders = append(b.Transponders, toTransponder(t))
|
||||||
|
}
|
||||||
|
sort.Slice(b.Transponders, func(i, j int) bool {
|
||||||
|
return b.Transponders[i].DownLo < b.Transponders[j].DownLo
|
||||||
|
})
|
||||||
|
birds = append(birds, b)
|
||||||
|
added++
|
||||||
|
fmt.Printf(" NEW %5d %-24s %d transponder(s)\n", n, b.Name, len(b.Transponders))
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(birds, func(i, j int) bool { return birds[i].Name < birds[j].Name })
|
||||||
|
out, err := json.MarshalIndent(birds, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(birdsPath, append(out, '\n'), 0o644); err != nil {
|
||||||
|
die(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nwrote %s — %d satellites (%d new)\n", birdsPath, len(birds), added)
|
||||||
|
}
|
||||||
|
|
||||||
|
// usable decides whether a SatNOGS transmitter is something an operator can
|
||||||
|
// work through.
|
||||||
|
//
|
||||||
|
// The database holds every emission a satellite makes, and most of them are not
|
||||||
|
// a contact: a telemetry beacon with a command uplink is listed exactly like an
|
||||||
|
// FM repeater, and shipping the command channel as a transponder would invite
|
||||||
|
// somebody to transmit on it. So both ends must exist, and anything that
|
||||||
|
// describes itself as telemetry or control is refused unless it also calls
|
||||||
|
// itself a repeater, a transponder or a digipeater.
|
||||||
|
func usable(t transmitter) bool {
|
||||||
|
if !t.Alive || t.Status != "active" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if t.UplinkLow <= 0 || t.DownlinkLow <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
d := strings.ToLower(t.Description)
|
||||||
|
isWorkable := strings.Contains(d, "repeater") || strings.Contains(d, "transponder") ||
|
||||||
|
strings.Contains(d, "digipeater") || strings.Contains(d, "aprs") ||
|
||||||
|
strings.Contains(d, "voice") || strings.Contains(d, "sstv") || strings.Contains(d, "dstar")
|
||||||
|
if isWorkable {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"telemetry", "command", "control", "dtmf", "beacon", "tlm"} {
|
||||||
|
if strings.Contains(d, bad) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An ANALOG emission with both ends is a contact by construction: nobody
|
||||||
|
// puts an FM or SSB uplink on a satellite for housekeeping. This is what
|
||||||
|
// catches the plainly-described repeaters — AO-27 says only "Mode V/U FM",
|
||||||
|
// and rejecting it for not using the word "repeater" would have dropped one
|
||||||
|
// of the best-known FM birds there is.
|
||||||
|
if m := adifMode(t.Mode); m == "FM" || m == "SSB" || m == "CW" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A digital emission has to say what it is. A GMSK uplink is a command
|
||||||
|
// channel far more often than it is a digipeater, and shipping the wrong one
|
||||||
|
// invites an operator to transmit on a control frequency.
|
||||||
|
return t.Type == "Transponder" || (t.UplinkHigh > t.UplinkLow && t.DownlinkHigh > t.DownlinkLow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctcssRe pulls a tone out of prose. SatNOGS has no field for it, and it is not
|
||||||
|
// optional: an FM uplink without the right tone opens nothing at all.
|
||||||
|
var ctcssRe = regexp.MustCompile(`(?i)(?:ctcss|pl)[^0-9]{0,4}(\d{2,3}(?:\.\d)?)|(\d{2,3}(?:\.\d)?)\s*(?:hz)?\s*(?:ctcss|pl)\b`)
|
||||||
|
|
||||||
|
func toTransponder(t transmitter) sat.Transponder {
|
||||||
|
tp := sat.Transponder{
|
||||||
|
Label: cleanLabel(t.Description),
|
||||||
|
Mode: adifMode(t.Mode),
|
||||||
|
DownLo: t.DownlinkLow,
|
||||||
|
DownHi: t.DownlinkHigh,
|
||||||
|
UpLo: t.UplinkLow,
|
||||||
|
UpHi: t.UplinkHigh,
|
||||||
|
Inverting: t.Invert,
|
||||||
|
}
|
||||||
|
// A "high" equal to the "low" is SatNOGS saying "a channel", not a one-hertz
|
||||||
|
// passband; Transponder.Linear() must not be fooled into interpolating.
|
||||||
|
if tp.DownHi <= tp.DownLo {
|
||||||
|
tp.DownHi = 0
|
||||||
|
}
|
||||||
|
if tp.UpHi <= tp.UpLo {
|
||||||
|
tp.UpHi = 0
|
||||||
|
}
|
||||||
|
// Inversion is a property of a PASSBAND. SatNOGS sets the flag on some FM
|
||||||
|
// channels too, where it means nothing — the code ignores it there, but a
|
||||||
|
// data file that says an FM repeater inverts is a data file that will
|
||||||
|
// mislead the next person to read it.
|
||||||
|
if tp.DownHi == 0 || tp.UpHi == 0 {
|
||||||
|
tp.Inverting = false
|
||||||
|
}
|
||||||
|
if m := ctcssRe.FindStringSubmatch(t.Description); m != nil {
|
||||||
|
v := m[1]
|
||||||
|
if v == "" {
|
||||||
|
v = m[2]
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 60 && f <= 260 {
|
||||||
|
tp.CTCSS = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tp
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifMode maps SatNOGS' modulation names onto the four modes a log knows.
|
||||||
|
func adifMode(m string) string {
|
||||||
|
switch u := strings.ToUpper(strings.TrimSpace(m)); {
|
||||||
|
case strings.HasPrefix(u, "FM"), u == "SSTV", u == "DSTAR", u == "NFM":
|
||||||
|
return "FM"
|
||||||
|
case u == "USB", u == "LSB", u == "SSB":
|
||||||
|
return "SSB"
|
||||||
|
case u == "CW":
|
||||||
|
return "CW"
|
||||||
|
default:
|
||||||
|
return "DATA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanLabel(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return "Transponder"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayName prefers the OSCAR designation an operator says out loud.
|
||||||
|
// "SAUDISAT 1C (SO-50)" is SO-50 to everybody except a catalog.
|
||||||
|
func displayName(feedName string) string {
|
||||||
|
s := strings.TrimSpace(feedName)
|
||||||
|
if i := strings.IndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") {
|
||||||
|
inner := strings.TrimSpace(s[i+1 : len(s)-1])
|
||||||
|
if oscarRe.MatchString(inner) {
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "RS-44 & BREEZE-KM R/B" — the rocket body it flies with is not its name.
|
||||||
|
if i := strings.Index(s, " & "); i > 0 {
|
||||||
|
return strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
var oscarRe = regexp.MustCompile(`^[A-Z]{1,3}-\d{1,3}$`)
|
||||||
|
|
||||||
|
// noradFor finds a curated entry's catalog number by the name matching the
|
||||||
|
// package already does.
|
||||||
|
//
|
||||||
|
// Deterministic on purpose. One satellite can hold TWO catalog entries — a
|
||||||
|
// deployment catalogued before the objects were told apart, GreenCube being
|
||||||
|
// 53106 and 53109 in the two feeds — and iterating the map picked a different
|
||||||
|
// one each run, so the generated file changed for no reason and the diff was
|
||||||
|
// unreadable. Candidates are therefore scored and tied on the lower number:
|
||||||
|
// a feed name whose designation IS the bird's name ("GREENCUBE (IO-117)" for
|
||||||
|
// IO-117) beats one that only matches through an alias.
|
||||||
|
func noradFor(b sat.Bird, feed map[int]string) (int, bool) {
|
||||||
|
nums := make([]int, 0, len(feed))
|
||||||
|
for n := range feed {
|
||||||
|
nums = append(nums, n)
|
||||||
|
}
|
||||||
|
sort.Ints(nums)
|
||||||
|
|
||||||
|
best, bestScore := 0, -1
|
||||||
|
for _, n := range nums {
|
||||||
|
name := feed[n]
|
||||||
|
if !b.Matches(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
if strings.EqualFold(displayName(name), b.Name) {
|
||||||
|
score = 2
|
||||||
|
} else if strings.EqualFold(strings.TrimSpace(name), b.Name) {
|
||||||
|
score = 1
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
best, bestScore = n, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, best != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFeeds() (map[int]string, error) {
|
||||||
|
out := map[int]string{}
|
||||||
|
for _, url := range tleFeeds {
|
||||||
|
body, err := get(url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: %s: %v\n", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
for _, l := range strings.Split(string(body), "\n") {
|
||||||
|
if s := strings.TrimSpace(l); s != "" {
|
||||||
|
lines = append(lines, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i+2 < len(lines); i += 3 {
|
||||||
|
if !strings.HasPrefix(lines[i+1], "1 ") || len(lines[i+1]) < 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(lines[i+1][2:7]))
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// First feed wins: Celestrak's spelling is the one the operator sees.
|
||||||
|
if _, had := out[n]; !had {
|
||||||
|
out[n] = lines[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, fmt.Errorf("no elements from any feed")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decayed reports whether this bird's spacecraft has re-entered, matching on
|
||||||
|
// the catalog number when we have one and on the designations SatNOGS lists
|
||||||
|
// otherwise — "NAYIF-1" carries "EO-88" only in its alternative names.
|
||||||
|
func decayed(b sat.Bird, cat []satellite) (satellite, bool) {
|
||||||
|
for _, s := range cat {
|
||||||
|
if s.Status != "re-entered" && s.Decayed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.NORAD != 0 {
|
||||||
|
if s.NORAD == b.NORAD {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names := append(strings.FieldsFunc(s.Names, func(r rune) bool { return r == ',' || r == '\n' }), s.Name)
|
||||||
|
for _, n := range names {
|
||||||
|
if strings.TrimSpace(n) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Matches(strings.TrimSpace(n)) {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return satellite{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSatellites() ([]satellite, error) {
|
||||||
|
body, err := get(satnogsSats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []satellite
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("satnogs satellites: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTransmitters() ([]transmitter, error) {
|
||||||
|
body, err := get(satnogsTX)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []transmitter
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("satnogs: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBirds() ([]sat.Bird, error) {
|
||||||
|
b, err := os.ReadFile(birdsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []sat.Bird
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", birdsPath, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(url string) ([]byte, error) {
|
||||||
|
c := &http.Client{Timeout: 90 * time.Second}
|
||||||
|
resp, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
func die(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, "satgen:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
+71
-1
@@ -114,7 +114,12 @@ func (t Transponder) Centre() int64 {
|
|||||||
|
|
||||||
// Bird is one satellite's frequency plan.
|
// Bird is one satellite's frequency plan.
|
||||||
type Bird struct {
|
type Bird struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
// NORAD is the catalog number, and the only exact way to find this
|
||||||
|
// satellite's elements: the feed, AMSAT and the operator all spell the NAME
|
||||||
|
// differently, while the number is carried inside the TLE itself. Aliases
|
||||||
|
// remain for the entries that predate it and for a hand-written plan.
|
||||||
|
NORAD int `json:"norad,omitempty"`
|
||||||
Aliases []string `json:"aliases,omitempty"`
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
|
// Geostationary: no pass, no Doppler worth correcting, a fixed look angle.
|
||||||
// QO-100 is the reason the flag exists, and it changes what the whole
|
// QO-100 is the reason the flag exists, and it changes what the whole
|
||||||
@@ -190,6 +195,22 @@ func LoadBirds(dir string) (*Birds, error) {
|
|||||||
_ = b.parse(shippedBirds)
|
_ = b.parse(shippedBirds)
|
||||||
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
return b, fmt.Errorf("sat: %s could not be read (%w) — the shipped list is in use for this session, and your file has been left alone", BirdsName, perr)
|
||||||
}
|
}
|
||||||
|
// New satellites reach an EXISTING station too.
|
||||||
|
//
|
||||||
|
// The operator's copy is written once, on the first run, and was then
|
||||||
|
// theirs for ever — which meant a release that added nine Tevel-2
|
||||||
|
// satellites reached nobody who had already opened the tab. Merging on
|
||||||
|
// each load fixes that without taking anything back: a satellite the
|
||||||
|
// operator already has is left exactly as it is, edits included, and
|
||||||
|
// only the ones they have never seen are added. Deleting a bird from the
|
||||||
|
// file therefore brings it back, which is the price of the trade — and
|
||||||
|
// the cheaper half of it, since an unwanted satellite is one row and a
|
||||||
|
// missing one is a pass nobody can work.
|
||||||
|
if n := b.addMissing(shippedBirds); n > 0 {
|
||||||
|
if out, merr := json.MarshalIndent(b.list, "", " "); merr == nil {
|
||||||
|
_ = os.WriteFile(path, append(out, '\n'), 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
if perr := b.parse(shippedBirds); perr != nil {
|
if perr := b.parse(shippedBirds); perr != nil {
|
||||||
@@ -283,3 +304,52 @@ func (b *Birds) Len() int {
|
|||||||
defer b.mu.RUnlock()
|
defer b.mu.RUnlock()
|
||||||
return len(b.list)
|
return len(b.list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addMissing appends the satellites in `shipped` that this list does not already
|
||||||
|
// hold, and reports how many were added.
|
||||||
|
//
|
||||||
|
// "Already hold" is by catalog number first and by the loose name second, so an
|
||||||
|
// operator who renamed a bird, or who has it under the feed's spelling, does not
|
||||||
|
// get a second copy of it. Nothing existing is touched: their frequencies, their
|
||||||
|
// labels and their corrections all stand.
|
||||||
|
func (b *Birds) addMissing(shipped []byte) int {
|
||||||
|
var list []Bird
|
||||||
|
if err := json.Unmarshal(shipped, &list); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
have := make(map[int]bool, len(b.list))
|
||||||
|
for _, x := range b.list {
|
||||||
|
if x.NORAD != 0 {
|
||||||
|
have[x.NORAD] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added := 0
|
||||||
|
for _, cand := range list {
|
||||||
|
if cand.NORAD != 0 && have[cand.NORAD] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
known := false
|
||||||
|
for _, name := range append([]string{cand.Name}, cand.Aliases...) {
|
||||||
|
if k := loose(name); k != "" {
|
||||||
|
if _, ok := b.byKey[k]; ok {
|
||||||
|
known = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if known {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.list = append(b.list, cand)
|
||||||
|
if cand.NORAD != 0 {
|
||||||
|
have[cand.NORAD] = true
|
||||||
|
}
|
||||||
|
if k := loose(cand.Name); k != "" {
|
||||||
|
b.byKey[k] = len(b.list) - 1
|
||||||
|
}
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|||||||
+566
-304
@@ -1,6 +1,312 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"name": "AO-123",
|
||||||
|
"norad": 61781,
|
||||||
|
"aliases": [
|
||||||
|
"ASRTU-1 (AO-123)"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transceiver",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 435400000,
|
||||||
|
"up_lo": 145850000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-27",
|
||||||
|
"norad": 22825,
|
||||||
|
"aliases": [
|
||||||
|
"EYESAT A (AO-27)"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U FM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436795000,
|
||||||
|
"up_lo": 145850000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-7",
|
||||||
|
"norad": 7530,
|
||||||
|
"aliases": [
|
||||||
|
"AMSAT-OSCAR 7",
|
||||||
|
"OSCAR 7"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode B linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145925000,
|
||||||
|
"down_hi": 145975000,
|
||||||
|
"up_lo": 432125000,
|
||||||
|
"up_hi": 432175000,
|
||||||
|
"inverting": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Mode A linear",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 29400000,
|
||||||
|
"down_hi": 29500000,
|
||||||
|
"up_lo": 145850000,
|
||||||
|
"up_hi": 145950000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-73",
|
||||||
|
"norad": 39444,
|
||||||
|
"aliases": [
|
||||||
|
"FUNCUBE-1",
|
||||||
|
"FUNCUBE 1"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 145950000,
|
||||||
|
"down_hi": 145970000,
|
||||||
|
"up_lo": 435130000,
|
||||||
|
"up_hi": 435150000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AO-91",
|
||||||
|
"norad": 43017,
|
||||||
|
"aliases": [
|
||||||
|
"RADFXSAT",
|
||||||
|
"FOX-1B",
|
||||||
|
"RADFXSAT (FOX-1B)"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145960000,
|
||||||
|
"up_lo": 435250000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "BEESAT-1",
|
||||||
|
"norad": 35933,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Digipeater (Mobitex, idle mode: 10 sec interval)",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 435950000,
|
||||||
|
"up_lo": 435950000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CO-65",
|
||||||
|
"norad": 32785,
|
||||||
|
"aliases": [
|
||||||
|
"CUTE-1.7+APD II (CO-65)"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode L/U Digipeater",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 437475000,
|
||||||
|
"up_lo": 1267600000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CROCUBE",
|
||||||
|
"norad": 62394,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode U/U - GFSK9k6 - Digipeater - AX.25",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 436775000,
|
||||||
|
"up_lo": 436775000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CSS",
|
||||||
|
"norad": 48274,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "3A V/V digipeater AFSK-FM 1200",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 145825000,
|
||||||
|
"up_lo": 145825000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "1A V/V crew voice NFM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145985000,
|
||||||
|
"up_lo": 145850000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "2B U/V FM repeater NFM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145985000,
|
||||||
|
"up_lo": 435075000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "4A V/V imaging SSTV-FM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145985000,
|
||||||
|
"up_lo": 145850000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "1B U/U crew voice NFM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436510000,
|
||||||
|
"up_lo": 435050000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "2A V/U FM repeater NFM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436510000,
|
||||||
|
"up_lo": 145875000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "4B U/U imaging SSTV-FM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436510000,
|
||||||
|
"up_lo": 435050000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "3B U/U digipeater AFSK-FM 1200",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 437550000,
|
||||||
|
"up_lo": 437550000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ESEO",
|
||||||
|
"norad": 43792,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145895000,
|
||||||
|
"up_lo": 1263500000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FLORIPASAT-1",
|
||||||
|
"norad": 44885,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode U/U GFSK2k4 Repeater",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 436100000,
|
||||||
|
"up_lo": 436100000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FO-29",
|
||||||
|
"norad": 24278,
|
||||||
|
"aliases": [
|
||||||
|
"JAS-2",
|
||||||
|
"FUJI-OSCAR 29"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear (inverting)",
|
||||||
|
"mode": "SSB",
|
||||||
|
"down_lo": 435800000,
|
||||||
|
"down_hi": 435900000,
|
||||||
|
"up_lo": 145900000,
|
||||||
|
"up_hi": 146000000,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "FORESAIL-1P",
|
||||||
|
"norad": 66778,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode U/U - GMSK9k6 - Digipeater - Skylink",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 437125000,
|
||||||
|
"up_lo": 437125000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GRBBETA",
|
||||||
|
"norad": 60237,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/V - GFSK9k6 - Digipeater - AX.25",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 145935000,
|
||||||
|
"up_lo": 145935000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Mode U/U - GFSK9k6 - Digipeater - AX.25",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 436785000,
|
||||||
|
"up_lo": 436785000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IO-117",
|
||||||
|
"norad": 53109,
|
||||||
|
"aliases": [
|
||||||
|
"GREENCUBE",
|
||||||
|
"MEZTLI"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Digipeater (1200 bd GMSK)",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 435310000,
|
||||||
|
"up_lo": 435310000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IO-86",
|
||||||
|
"norad": 40931,
|
||||||
|
"aliases": [
|
||||||
|
"LAPAN-A2",
|
||||||
|
"LAPAN-ORARI"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 435880000,
|
||||||
|
"up_lo": 145880000,
|
||||||
|
"ctcss": 88.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ISAT",
|
||||||
|
"norad": 43879,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "MODE U/U DSTAR VOICE",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 435525000,
|
||||||
|
"up_lo": 437325000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ISS (ZARYA)",
|
"name": "ISS (ZARYA)",
|
||||||
|
"norad": 25544,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"ISS",
|
"ISS",
|
||||||
"ZARYA",
|
"ZARYA",
|
||||||
@@ -27,135 +333,9 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "SO-50",
|
|
||||||
"aliases": [
|
|
||||||
"SAUDISAT 1C",
|
|
||||||
"SAUDISAT 1C (SO-50)"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436795000,
|
|
||||||
"up_lo": 145850000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AO-91",
|
|
||||||
"aliases": [
|
|
||||||
"RADFXSAT",
|
|
||||||
"FOX-1B",
|
|
||||||
"RADFXSAT (FOX-1B)"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 145960000,
|
|
||||||
"up_lo": 435250000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IO-86",
|
|
||||||
"aliases": [
|
|
||||||
"LAPAN-A2",
|
|
||||||
"LAPAN-ORARI"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 435880000,
|
|
||||||
"up_lo": 145880000,
|
|
||||||
"ctcss": 88.5
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PO-101",
|
|
||||||
"aliases": [
|
|
||||||
"DIWATA-2",
|
|
||||||
"DIWATA-2B"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater (scheduled)",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 145900000,
|
|
||||||
"up_lo": 437500000,
|
|
||||||
"ctcss": 141.3
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AO-7",
|
|
||||||
"aliases": [
|
|
||||||
"AMSAT-OSCAR 7",
|
|
||||||
"OSCAR 7"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "Mode B linear (inverting)",
|
|
||||||
"mode": "SSB",
|
|
||||||
"down_lo": 145925000,
|
|
||||||
"down_hi": 145975000,
|
|
||||||
"up_lo": 432125000,
|
|
||||||
"up_hi": 432175000,
|
|
||||||
"inverting": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Mode A linear",
|
|
||||||
"mode": "SSB",
|
|
||||||
"down_lo": 29400000,
|
|
||||||
"down_hi": 29500000,
|
|
||||||
"up_lo": 145850000,
|
|
||||||
"up_hi": 145950000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "FO-29",
|
|
||||||
"aliases": [
|
|
||||||
"JAS-2",
|
|
||||||
"FUJI-OSCAR 29"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "Linear (inverting)",
|
|
||||||
"mode": "SSB",
|
|
||||||
"down_lo": 435800000,
|
|
||||||
"down_hi": 435900000,
|
|
||||||
"up_lo": 145900000,
|
|
||||||
"up_hi": 146000000,
|
|
||||||
"inverting": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AO-73",
|
|
||||||
"aliases": [
|
|
||||||
"FUNCUBE-1",
|
|
||||||
"FUNCUBE 1"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "Linear (inverting)",
|
|
||||||
"mode": "SSB",
|
|
||||||
"down_lo": 145950000,
|
|
||||||
"down_hi": 145970000,
|
|
||||||
"up_lo": 435130000,
|
|
||||||
"up_hi": 435150000,
|
|
||||||
"inverting": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "JO-97",
|
"name": "JO-97",
|
||||||
|
"norad": 43803,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"JY1SAT",
|
"JY1SAT",
|
||||||
"JY1-SAT"
|
"JY1-SAT"
|
||||||
@@ -173,24 +353,117 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "RS-44",
|
"name": "KNACKSAT-2",
|
||||||
|
"norad": 67683,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/V - FSK9k6 - Digipeater - AX.25 G3RUH",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 145825000,
|
||||||
|
"up_lo": 145825000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KOSEN-1",
|
||||||
|
"norad": 49402,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode HF/U - Onboard SDR",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 435525000,
|
||||||
|
"up_lo": 21125000,
|
||||||
|
"up_hi": 21150000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LASARSAT",
|
||||||
|
"norad": 62391,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode U/U - GFSK9k6 - Digipeater - AX.25",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 436925000,
|
||||||
|
"up_lo": 436925000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LILACSAT-2",
|
||||||
|
"norad": 40908,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "APRS Digipeater",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 144390000,
|
||||||
|
"up_lo": 144390000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NO-44",
|
||||||
|
"norad": 26931,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"DOSAAF-85"
|
"PCSAT (NO-44)"
|
||||||
],
|
],
|
||||||
"transponders": [
|
"transponders": [
|
||||||
{
|
{
|
||||||
"label": "Linear (inverting)",
|
"label": "Mode V/V APRS AFSK",
|
||||||
|
"mode": "DATA",
|
||||||
|
"down_lo": 145825000,
|
||||||
|
"up_lo": 145825000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PO-101",
|
||||||
|
"norad": 43678,
|
||||||
|
"aliases": [
|
||||||
|
"DIWATA-2",
|
||||||
|
"DIWATA-2B"
|
||||||
|
],
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "FM voice repeater (scheduled)",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 145900000,
|
||||||
|
"up_lo": 437500000,
|
||||||
|
"ctcss": 141.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "QB50P1",
|
||||||
|
"norad": 40025,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Linear Transponder",
|
||||||
"mode": "SSB",
|
"mode": "SSB",
|
||||||
"down_lo": 435640000,
|
"down_lo": 145935000,
|
||||||
"down_hi": 435680000,
|
"down_hi": 145965000,
|
||||||
"up_lo": 145965000,
|
"up_lo": 435047000,
|
||||||
"up_hi": 146005000,
|
"up_hi": 435077000,
|
||||||
"inverting": true
|
"inverting": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "QMR-KWT-2",
|
||||||
|
"norad": 67291,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "V/U FM Transponder CTCSS 67.0 Hz",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436950000,
|
||||||
|
"up_lo": 145920000,
|
||||||
|
"ctcss": 67
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "QO-100",
|
"name": "QO-100",
|
||||||
|
"norad": 43700,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"ES'HAIL 2",
|
"ES'HAIL 2",
|
||||||
"ESHAIL 2",
|
"ESHAIL 2",
|
||||||
@@ -217,199 +490,176 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "TEVEL-1",
|
"name": "RS-44",
|
||||||
|
"norad": 44909,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"TEVEL 1"
|
"DOSAAF-85"
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-2",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 2"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-3",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 3"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-4",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 4"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-5",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 5"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-6",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 6"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-7",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 7"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TEVEL-8",
|
|
||||||
"aliases": [
|
|
||||||
"TEVEL 8"
|
|
||||||
],
|
|
||||||
"transponders": [
|
|
||||||
{
|
|
||||||
"label": "FM voice repeater",
|
|
||||||
"mode": "FM",
|
|
||||||
"down_lo": 436400000,
|
|
||||||
"up_lo": 145970000,
|
|
||||||
"ctcss": 67
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "EO-88",
|
|
||||||
"aliases": [
|
|
||||||
"NAYIF-1",
|
|
||||||
"FUNCUBE-5"
|
|
||||||
],
|
],
|
||||||
"transponders": [
|
"transponders": [
|
||||||
{
|
{
|
||||||
"label": "Linear (inverting)",
|
"label": "Linear (inverting)",
|
||||||
"mode": "SSB",
|
"mode": "SSB",
|
||||||
"down_lo": 145960000,
|
"down_lo": 435640000,
|
||||||
"down_hi": 145990000,
|
"down_hi": 435680000,
|
||||||
"up_lo": 435015000,
|
"up_lo": 145965000,
|
||||||
"up_hi": 435045000,
|
"up_hi": 146005000,
|
||||||
"inverting": true
|
"inverting": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "AO-109",
|
"name": "SO-50",
|
||||||
|
"norad": 27607,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"RADFXSAT-2",
|
"SAUDISAT 1C",
|
||||||
"FOX-1E"
|
"SAUDISAT 1C (SO-50)"
|
||||||
],
|
],
|
||||||
"transponders": [
|
"transponders": [
|
||||||
{
|
{
|
||||||
"label": "Linear (inverting)",
|
"label": "FM voice repeater",
|
||||||
"mode": "SSB",
|
"mode": "FM",
|
||||||
"down_lo": 145860000,
|
"down_lo": 436795000,
|
||||||
"down_hi": 145880000,
|
"up_lo": 145850000,
|
||||||
"up_lo": 435750000,
|
"ctcss": 67
|
||||||
"up_hi": 435770000,
|
|
||||||
"inverting": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CAS-4A",
|
"name": "SONATE-2",
|
||||||
"aliases": [
|
"norad": 59112,
|
||||||
"ZHUHAI-1 OVS-1A",
|
|
||||||
"OVS-1A"
|
|
||||||
],
|
|
||||||
"transponders": [
|
"transponders": [
|
||||||
{
|
{
|
||||||
"label": "Linear (inverting)",
|
"label": "Mode V/V - APRS digipeater",
|
||||||
"mode": "SSB",
|
"mode": "DATA",
|
||||||
"down_lo": 145860000,
|
"down_lo": 145825000,
|
||||||
"down_hi": 145880000,
|
"up_lo": 145825000
|
||||||
"up_lo": 435210000,
|
|
||||||
"up_hi": 435230000,
|
|
||||||
"inverting": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CAS-4B",
|
"name": "TAURUS-1",
|
||||||
"aliases": [
|
"norad": 44530,
|
||||||
"ZHUHAI-1 OVS-1B",
|
|
||||||
"OVS-1B"
|
|
||||||
],
|
|
||||||
"transponders": [
|
"transponders": [
|
||||||
{
|
{
|
||||||
"label": "Linear (inverting)",
|
"label": "Mode V/U FM 67.0 PL",
|
||||||
"mode": "SSB",
|
"mode": "FM",
|
||||||
"down_lo": 145905000,
|
"down_lo": 436760000,
|
||||||
"down_hi": 145925000,
|
"up_lo": 145820000,
|
||||||
"up_lo": 435270000,
|
"ctcss": 67
|
||||||
"up_hi": 435290000,
|
}
|
||||||
"inverting": true
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-1",
|
||||||
|
"norad": 63217,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-2",
|
||||||
|
"norad": 63219,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder - Beacon",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-3",
|
||||||
|
"norad": 63218,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-4",
|
||||||
|
"norad": 63213,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-5",
|
||||||
|
"norad": 63214,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-6",
|
||||||
|
"norad": 63215,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-7",
|
||||||
|
"norad": 63238,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-8",
|
||||||
|
"norad": 63239,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder - Beacon",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TEVEL2-9",
|
||||||
|
"norad": 63237,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U - FM Transponder",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 436400000,
|
||||||
|
"up_lo": 145970000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "TO-108",
|
"name": "TO-108",
|
||||||
|
"norad": 44881,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
"CAS-6",
|
"CAS-6",
|
||||||
"TIANQIN-1"
|
"TIANQIN-1"
|
||||||
@@ -427,17 +677,29 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "IO-117",
|
"name": "UKUBE-1",
|
||||||
"aliases": [
|
"norad": 40074,
|
||||||
"GREENCUBE",
|
|
||||||
"MEZTLI"
|
|
||||||
],
|
|
||||||
"transponders": [
|
"transponders": [
|
||||||
{
|
{
|
||||||
"label": "Digipeater (1200 bd GMSK)",
|
"label": "Inverting linear transponder",
|
||||||
"mode": "DATA",
|
"mode": "SSB",
|
||||||
"down_lo": 435310000,
|
"down_lo": 145930000,
|
||||||
"up_lo": 435310000
|
"down_hi": 145950000,
|
||||||
|
"up_lo": 435074300,
|
||||||
|
"up_hi": 435094300,
|
||||||
|
"inverting": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "XIWANG-1 (HOPE-1)",
|
||||||
|
"norad": 36122,
|
||||||
|
"transponders": [
|
||||||
|
{
|
||||||
|
"label": "Mode V/U FM",
|
||||||
|
"mode": "FM",
|
||||||
|
"down_lo": 435675000,
|
||||||
|
"up_lo": 145825000
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sat
|
package sat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -158,8 +159,19 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
|||||||
t.Fatalf("the editable copy was not written: %v", err)
|
t.Fatalf("the editable copy was not written: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An operator's own list is what gets used from then on.
|
// An operator's own list is kept, AND the shipped satellites they have never
|
||||||
mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]`
|
// seen are added to it.
|
||||||
|
//
|
||||||
|
// The merge is what carries a new satellite to a station that has already
|
||||||
|
// run OpsLog once: without it, the copy written on the very first launch was
|
||||||
|
// the operator's list for ever, and a release adding nine Tevel-2 birds
|
||||||
|
// reached nobody. What it must never do is take something back — so the
|
||||||
|
// operator's own satellite, and their correction to a shipped one, both have
|
||||||
|
// to survive it.
|
||||||
|
mine := `[
|
||||||
|
{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]},
|
||||||
|
{"name":"SO-50","norad":27607,"transponders":[{"label":"corrected","mode":"FM","down_lo":436796000,"up_lo":145850000,"ctcss":74.4}]}
|
||||||
|
]`
|
||||||
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(mine), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -167,8 +179,23 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if b.Len() != 1 {
|
if b.Len() < shipped {
|
||||||
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
|
t.Fatalf("the shipped satellites were not merged in: %d, expected at least %d", b.Len(), shipped)
|
||||||
|
}
|
||||||
|
if _, ok := b.Find("MY-SAT"); !ok {
|
||||||
|
t.Error("the operator's own satellite was dropped by the merge")
|
||||||
|
}
|
||||||
|
// Their correction stands: same tone, same frequency, not the shipped one.
|
||||||
|
got, ok := b.Find("SO-50")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SO-50 vanished")
|
||||||
|
}
|
||||||
|
if len(got.Transponders) != 1 || got.Transponders[0].CTCSS != 74.4 || got.Transponders[0].DownLo != 436796000 {
|
||||||
|
t.Errorf("the operator's correction to SO-50 was overwritten: %+v", got.Transponders)
|
||||||
|
}
|
||||||
|
// And a shipped satellite they had never seen is now there.
|
||||||
|
if _, ok := b.Find("AO-7"); !ok {
|
||||||
|
t.Error("a shipped satellite was not added to the operator's list")
|
||||||
}
|
}
|
||||||
|
|
||||||
// And a broken one falls back without destroying what they wrote.
|
// And a broken one falls back without destroying what they wrote.
|
||||||
@@ -186,3 +213,58 @@ func TestLoadBirdsWritesTheEditableCopy(t *testing.T) {
|
|||||||
t.Error("the operator's broken file was overwritten")
|
t.Error("the operator's broken file was overwritten")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shipped plan is generated (cmd/satgen) from public databases, so it is
|
||||||
|
// worth its own guard, on top of TestShippedBirds above: a bad regeneration
|
||||||
|
// should fail here rather than mistune an antenna on somebody first pass.
|
||||||
|
func TestGeneratedBirdsAreSane(t *testing.T) {
|
||||||
|
var list []Bird
|
||||||
|
if err := json.Unmarshal(shippedBirds, &list); err != nil {
|
||||||
|
t.Fatalf("birds.json does not parse: %v", err)
|
||||||
|
}
|
||||||
|
// The generator joins three feeds. If one of them answered with nothing, the
|
||||||
|
// output silently shrinks, and this is where that shows up.
|
||||||
|
if len(list) < 30 {
|
||||||
|
t.Errorf("only %d satellites shipped — the generator probably ran against an empty feed", len(list))
|
||||||
|
}
|
||||||
|
seenNORAD := map[int]string{}
|
||||||
|
seenName := map[string]bool{}
|
||||||
|
for _, b := range list {
|
||||||
|
if b.Name == "" {
|
||||||
|
t.Error("a satellite with no name")
|
||||||
|
}
|
||||||
|
if seenName[loose(b.Name)] {
|
||||||
|
t.Errorf("%s appears twice", b.Name)
|
||||||
|
}
|
||||||
|
seenName[loose(b.Name)] = true
|
||||||
|
// Two entries for one catalog number is one satellite the operator can
|
||||||
|
// pick twice, with two different sets of frequencies.
|
||||||
|
if b.NORAD != 0 {
|
||||||
|
if other, dup := seenNORAD[b.NORAD]; dup {
|
||||||
|
t.Errorf("NORAD %d is both %s and %s", b.NORAD, other, b.Name)
|
||||||
|
}
|
||||||
|
seenNORAD[b.NORAD] = b.Name
|
||||||
|
}
|
||||||
|
for _, tp := range b.Transponders {
|
||||||
|
// An amateur satellite works between 15 m and 24 GHz. Anything outside
|
||||||
|
// that is a units mistake, and a units mistake is how a rig ends up
|
||||||
|
// commanded somewhere it cannot go.
|
||||||
|
for _, hz := range []int64{tp.DownLo, tp.DownHi, tp.UpLo, tp.UpHi} {
|
||||||
|
if hz != 0 && (hz < 21_000_000 || hz > 24_000_000_000) {
|
||||||
|
t.Errorf("%s: %d Hz is not an amateur satellite frequency", b.Name, hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inversion only means something across a passband. The code ignores
|
||||||
|
// the flag on a channel, but a file that claims an FM repeater inverts
|
||||||
|
// will mislead whoever reads it next.
|
||||||
|
if tp.Inverting && !tp.Linear() {
|
||||||
|
t.Errorf("%s: %q is a channel and cannot invert", b.Name, tp.Label)
|
||||||
|
}
|
||||||
|
switch tp.Mode {
|
||||||
|
case "FM", "SSB", "CW", "DATA":
|
||||||
|
default:
|
||||||
|
t.Errorf("%s: %q is not an ADIF mode the log can store", b.Name, tp.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,6 +186,27 @@ func (s *Store) Get(name string) (Element, bool) {
|
|||||||
return e, ok
|
return e, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNORAD returns one satellite's elements by catalog number.
|
||||||
|
//
|
||||||
|
// The exact join, and the only one that stays exact. A name is written
|
||||||
|
// differently by every party involved — the feed says "RADFXSAT (FOX-1B)", the
|
||||||
|
// operator says "AO-91", AMSAT's chart says both — and matching on letters and
|
||||||
|
// digits gets most of them and quietly misses the rest. The catalog number is
|
||||||
|
// in the TLE itself and is what a frequency plan should carry.
|
||||||
|
func (s *Store) GetNORAD(n int) (Element, bool) {
|
||||||
|
if n <= 0 {
|
||||||
|
return Element{}, false
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
for _, k := range s.order {
|
||||||
|
if e := s.byKey[k]; e.NORAD == n {
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Element{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// Names lists what the store holds, in the order it arrived.
|
// Names lists what the store holds, in the order it arrived.
|
||||||
func (s *Store) Names() []string {
|
func (s *Store) Names() []string {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
Reference in New Issue
Block a user