krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit 365129e599

365129e59955addbec6a89f274ff6b20c808541e

parent: 3b8c40e96a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:42 UTC

Launch payload first pass and Starlink train labels

- Migration 0004 adds launches.launch_designator. LL2 previous launches (limit 10) are polled on the first 10-minute run of each hour; launches are kept 14 days after NET. /v1/launches adds "recent" (NET before now - 6 h) and per-launch "elements" and "elementsSource".
- Satellite cron: after the two GP groups, up to 4 SupGP files (starlink-gX-Y) for SpaceX Starlink launches from 3 days ago to 48 h ahead. Non-JSON 200 replies are no data; any non-200 stops further requests and the last good "supgp" snapshot is used. SupGP sets are tied to launches by file name and epoch (0-3 h after NET before launch), not OBJECT_ID.
- Per launch, GP objects matched by designator or SupGP sets, whichever is newer: a Starlink train plus rocket bodies and other objects, no debris, at most 8. Trains are named "Starlink train (Group 15-25)" from the mission.
- App: Recent section in Launches; launch detail shows the first visible pass and two later passes of the same object, computed on the device, with an opt-in local notification 15 minutes before.

Layout: unified · split

App/Sources/LaunchPassNotifications.swift added +76
@@ -0,0 +1,76 @@
1import Feeds
2import SkyEngine
3import SwiftUI
4import UserNotifications
5
6/// Opt-in local notifications 15 minutes before a launch's first visible pass. Passes are worked out on the
7/// device and replaced whenever the launch list is loaded.
8@MainActor
9enum LaunchPassNotifications {
10 static let followedKey = "launchPassFollowed"
11 static let lead: TimeInterval = 15 * 60
12
13 static var followed: Set<String> {
14 get { Set(UserDefaults.standard.stringArray(forKey: followedKey) ?? []) }
15 set { UserDefaults.standard.set(Array(newValue), forKey: followedKey) }
16 }
17
18 private static func id(_ launch: Launch) -> String { "launch-pass-\(launch.id)" }
19
20 nonisolated static func passes(of launch: Launch, observer: Observer) async -> [SatellitePass] {
21 let elements = launch.elements ?? []
22 return await Task.detached { SkyEngine.visiblePasses(of: elements, from: .now, observer: observer) }.value
23 }
24
25 static func follow(_ launch: Launch) async -> Bool {
26 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
27 guard granted else { return false }
28 followed.insert(launch.id)
29 await schedule(launch, observer: libraryObserver())
30 return true
31 }
32
33 static func unfollow(_ launch: Launch) {
34 followed.remove(launch.id)
35 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id(launch)])
36 }
37
38 /// Replaces the notifications for followed launches in `launches`; followed launches no longer listed are dropped.
39 static func reschedule(_ launches: [Launch]) async {
40 let listed = Set(launches.map(\.id))
41 let center = UNUserNotificationCenter.current()
42 let stale = followed.subtracting(listed)
43 center.removePendingNotificationRequests(withIdentifiers: stale.map { "launch-pass-\($0)" })
44 followed.formIntersection(listed)
45 let observer = libraryObserver()
46 for launch in launches where followed.contains(launch.id) {
47 await schedule(launch, observer: observer)
48 }
49 }
50
51 /// Loads the launch list, when anything is followed, and reschedules.
52 static func refresh() async {
53 guard !followed.isEmpty, let apiBase else { return }
54 var list: LaunchList? = FeedMemo.get("launches", maxAge: 300)
55 if list == nil { list = try? await LaunchFeed.fetch(base: apiBase) }
56 guard let list else { return }
57 await reschedule(list.launches + (list.recent ?? []))
58 }
59
60 private static func schedule(_ launch: Launch, observer: Observer) async {
61 let center = UNUserNotificationCenter.current()
62 center.removePendingNotificationRequests(withIdentifiers: [id(launch)])
63 guard let pass = await passes(of: launch, observer: observer).first(where: { $0.rise.addingTimeInterval(-lead) > .now }) else { return }
64 let time = pass.rise.formatted(date: .omitted, time: .shortened)
65 let content = UNMutableNotificationContent()
66 content.title = "\(launch.mission ?? launch.rocket): first visible pass at \(time)"
67 content.body = "\(pass.displayName) rises in the \(compass(pass.riseAzimuth)), highest \(Int(pass.maxAltitude.rounded()))°, sets in the \(compass(pass.setAzimuth)). Sunlit while your sky is dark."
68 content.sound = .default
69 let parts = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: pass.rise.addingTimeInterval(-lead))
70 try? await center.add(UNNotificationRequest(identifier: id(launch), content: content, trigger: UNCalendarNotificationTrigger(dateMatching: parts, repeats: false)))
71 }
72
73 static func compass(_ azimuth: Double) -> String {
74 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
75 }
76}
App/Sources/LaunchesScreen.swift +83 −1
@@ -1,5 +1,6 @@
1import ActivityKit 1import ActivityKit
2import Feeds 2import Feeds
3import SkyEngine
3import SwiftUI 4import SwiftUI
4 5
5/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. 6/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset.
@@ -64,6 +65,13 @@ struct LaunchesScreen: View {
64 Button { selected = launch } label: { row(launch, index: i + 1) } 65 Button { selected = launch } label: { row(launch, index: i + 1) }
65 .buttonStyle(.plain) 66 .buttonStyle(.plain)
66 } 67 }
68 if let recent = list.recent, !recent.isEmpty {
69 Text("Recent").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 20).padding(.bottom, 4)
70 ForEach(Array(recent.enumerated()), id: \.element.id) { i, launch in
71 Button { selected = launch } label: { row(launch, index: i + 1) }
72 .buttonStyle(.plain)
73 }
74 }
67 Text("Launch data: The Space Devs, Launch Library 2.") 75 Text("Launch data: The Space Devs, Launch Library 2.")
68 .font(.caption2).dimmed(0.5).padding(.vertical, 16) 76 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
69 } else if error == nil { 77 } else if error == nil {
@@ -77,7 +85,10 @@ struct LaunchesScreen: View {
77 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return } 85 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
78 do { 86 do {
79 list = try await LaunchFeed.fetch(base: apiBase) 87 list = try await LaunchFeed.fetch(base: apiBase)
80 if let list { FeedMemo.set("launches", list) } 88 if let list {
89 FeedMemo.set("launches", list)
90 await LaunchPassNotifications.reschedule(list.launches + (list.recent ?? []))
91 }
81 error = nil 92 error = nil
82 } catch { 93 } catch {
83 self.error = "Couldn't load launches. Pull to retry." 94 self.error = "Couldn't load launches. Pull to retry."
@@ -168,6 +179,7 @@ struct LaunchDetail: View {
168 fact("Location", launch.location) 179 fact("Location", launch.location)
169 } 180 }
170 .font(.subheadline) 181 .font(.subheadline)
182 visibility.padding(.top, 8)
171 followButton.padding(.top, 8) 183 followButton.padding(.top, 8)
172 if let message { Text(message).font(.caption).dimmed(0.7) } 184 if let message { Text(message).font(.caption).dimmed(0.7) }
173 if let d = launch.missionDescription { 185 if let d = launch.missionDescription {
@@ -184,6 +196,76 @@ struct LaunchDetail: View {
184 196
185 @State private var message: String? 197 @State private var message: String?
186 @State private var following = false 198 @State private var following = false
199 @State private var passes: [SatellitePass]?
200 @State private var notifying = false
201 @State private var notifyMessage: String?
202
203 /// First visible passes over the observer, worked out on the device.
204 @ViewBuilder
205 private var visibility: some View {
206 if let elements = launch.elements, !elements.isEmpty {
207 VStack(alignment: .leading, spacing: 6) {
208 Text("Visibility").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
209 if let passes {
210 if let first = passes.first {
211 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
212 fact("First visible pass", describe(first))
213 ForEach(passes.filter { $0.name == first.name }.dropFirst().prefix(2)) { fact("Later", describe($0)) }
214 }
215 .font(.subheadline)
216 Text("\(first.displayName). Sunlit while your sky is dark; it may still be too faint to see\(elements.contains { $0.OBJECT_NAME.hasPrefix("Starlink train") } ? ", though new trains are often visible" : "").")
217 .font(.caption).dimmed(0.7)
218 Text(source).font(.caption2).dimmed(0.5)
219 notifyButton
220 if let notifyMessage { Text(notifyMessage).font(.caption).dimmed(0.7) }
221 } else {
222 Text("No visible pass from here in the next three days.").font(.subheadline).dimmed(0.7)
223 }
224 } else {
225 ProgressView().tint(ink)
226 }
227 }
228 .task {
229 notifying = LaunchPassNotifications.followed.contains(launch.id)
230 passes = await LaunchPassNotifications.passes(of: launch, observer: libraryObserver())
231 }
232 } else if launch.net < .now {
233 Text("No orbit published for this launch yet.").font(.caption).dimmed(0.7)
234 }
235 }
236
237 private var source: String {
238 let credit = launch.elementsSource == "supgp" ? "SpaceX via CelesTrak" : "CelesTrak"
239 return launch.elementsSource == "supgp" && launch.net > .now ? "Predicted before launch · \(credit)" : "Orbit: \(credit)"
240 }
241
242 private func describe(_ p: SatellitePass) -> String {
243 p.rise.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
244 + " · \(LaunchPassNotifications.compass(p.riseAzimuth)) → \(LaunchPassNotifications.compass(p.setAzimuth)), highest \(Int(p.maxAltitude.rounded()))°"
245 }
246
247 private var notifyButton: some View {
248 Button {
249 if notifying {
250 LaunchPassNotifications.unfollow(launch)
251 notifying = false
252 notifyMessage = nil
253 } else {
254 Task {
255 notifying = await LaunchPassNotifications.follow(launch)
256 notifyMessage = notifying ? nil : "Notifications are off for Moonrise. Turn them on in the Settings app."
257 }
258 }
259 } label: {
260 Label(notifying ? "Don't notify me" : "Notify me 15 minutes before", systemImage: notifying ? "bell.slash" : "bell")
261 .font(.subheadline.weight(.medium))
262 .frame(maxWidth: .infinity)
263 .padding(.vertical, 10)
264 .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
265 }
266 .buttonStyle(.plain)
267 .padding(.top, 4)
268 }
187 269
188 private var followButton: some View { 270 private var followButton: some View {
189 Button { 271 Button {
App/Sources/LibraryScreen.swift +1 −1
@@ -10,7 +10,7 @@ private let pageBackground = Color.black
10private let tile = Color(white: 0.08) 10private let tile = Color(white: 0.08)
11 11
12/// The observer from Settings (a chosen city) or the last known location, else Chicago. 12/// The observer from Settings (a chosen city) or the last known location, else Chicago.
13private func libraryObserver() -> Observer { 13func libraryObserver() -> Observer {
14 let d = UserDefaults.standard 14 let d = UserDefaults.standard
15 if d.string(forKey: SettingsKey.locationMode) == "city", d.object(forKey: SettingsKey.cityLatitude) != nil { 15 if d.string(forKey: SettingsKey.locationMode) == "city", d.object(forKey: SettingsKey.cityLatitude) != nil {
16 return Observer(latitude: d.double(forKey: SettingsKey.cityLatitude), longitude: d.double(forKey: SettingsKey.cityLongitude)) 16 return Observer(latitude: d.double(forKey: SettingsKey.cityLatitude), longitude: d.double(forKey: SettingsKey.cityLongitude))
App/Sources/MoonriseApp.swift +2
@@ -12,6 +12,7 @@ struct MoonriseApp: App {
12 @AppStorage(SettingsKey.onboarded) private var onboarded = false 12 @AppStorage(SettingsKey.onboarded) private var onboarded = false
13 @AppStorage(SettingsKey.nightActive) private var nightActive = false 13 @AppStorage(SettingsKey.nightActive) private var nightActive = false
14 @State private var router = Router.shared 14 @State private var router = Router.shared
15 @Environment(\.scenePhase) private var scenePhase
15 16
16 var body: some Scene { 17 var body: some Scene {
17 WindowGroup { 18 WindowGroup {
@@ -30,6 +31,7 @@ struct MoonriseApp: App {
30 .fullScreenCover(isPresented: .constant(!onboarded)) { 31 .fullScreenCover(isPresented: .constant(!onboarded)) {
31 OnboardingScreen().preferredColorScheme(.dark) 32 OnboardingScreen().preferredColorScheme(.dark)
32 } 33 }
34 .onChange(of: scenePhase) { if scenePhase == .active { Task { await LaunchPassNotifications.refresh() } } }
33 } 35 }
34 } 36 }
35} 37}
Packages/MoonriseKit/Package.swift +1 −1
@@ -24,7 +24,7 @@ let package = Package(
24 dependencies: ["SkyEngine"], 24 dependencies: ["SkyEngine"],
25 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json")] 25 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json")]
26 ), 26 ),
27 .target(name: "Feeds"), 27 .target(name: "Feeds", dependencies: ["SkyEngine"]),
28 .target(name: "ObservingLog"), 28 .target(name: "ObservingLog"),
29 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]), 29 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
30 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), 30 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
Packages/MoonriseKit/Sources/Feeds/Launches.swift +7
@@ -1,4 +1,5 @@
1import Foundation 1import Foundation
2import SkyEngine
2 3
3/// An upcoming launch as served by the Moonrise backend (from Launch Library 2). 4/// An upcoming launch as served by the Moonrise backend (from Launch Library 2).
4public struct Launch: Codable, Sendable, Identifiable, Hashable { 5public struct Launch: Codable, Sendable, Identifiable, Hashable {
@@ -20,11 +21,17 @@ public struct Launch: Codable, Sendable, Identifiable, Hashable {
20 public var location: String 21 public var location: String
21 public var padLatitude: Double? 22 public var padLatitude: Double?
22 public var padLongitude: Double? 23 public var padLongitude: Double?
24 /// Element sets for this launch's objects (a Starlink train, rocket bodies, payloads), once known.
25 public var elements: [OrbitalElements]?
26 /// "supgp" (SpaceX data via CelesTrak, possibly predicted before launch) or "gp" (CelesTrak catalogue).
27 public var elementsSource: String?
23} 28}
24 29
25public struct LaunchList: Codable, Sendable { 30public struct LaunchList: Codable, Sendable {
26 public var fetchedAt: Date? 31 public var fetchedAt: Date?
27 public var launches: [Launch] 32 public var launches: [Launch]
33 /// Launched in the last 14 days, latest first.
34 public var recent: [Launch]?
28} 35}
29 36
30public enum LaunchFeed { 37public enum LaunchFeed {
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +9
@@ -118,4 +118,13 @@ extension SkyEngine {
118 } 118 }
119 return out 119 return out
120 } 120 }
121
122 /// Visible passes of any of `elements`, earliest first, each set searched for `hours` from the later of `start`
123 /// and its epoch: a set published before launch is epoched at deployment and means nothing earlier.
124 public static func visiblePasses(of elements: [OrbitalElements], from start: Date, hours: Double = 72, observer: Observer) -> [SatellitePass] {
125 elements.flatMap { e in
126 passes(of: e, from: max(start, e.epochDate ?? start), hours: hours, observer: observer).filter(\.visible)
127 }
128 .sorted { $0.rise < $1.rise }
129 }
121} 130}
Packages/MoonriseKit/Tests/FeedsTests/LaunchTests.swift +19
@@ -18,4 +18,23 @@ import Testing
18 #expect(list.launches[0].net == (try Date("2026-10-19T10:30:00Z", strategy: .iso8601))) 18 #expect(list.launches[0].net == (try Date("2026-10-19T10:30:00Z", strategy: .iso8601)))
19 #expect(list.launches[1].mission == nil) 19 #expect(list.launches[1].mission == nil)
20 #expect(list.fetchedAt != nil) 20 #expect(list.fetchedAt != nil)
21 #expect(list.recent == nil)
22 #expect(list.launches[0].elements == nil)
23}
24
25@Test func decodesRecentLaunchesWithElements() throws {
26 // Shape of GET /v1/launches with launch element sets, 2026-10-19.
27 let data = Data("""
28 {"fetchedAt":"2026-10-19T12:00:00.000Z","launches":[],"recent":[{"id":"97c6f5e1","name":"Falcon 9 Block 5 | Starlink Group 15-25",
29 "net":"2026-10-13T00:28:11Z","windowStart":null,"windowEnd":null,"status":"Success","statusName":"Launch Successful","provider":"SpaceX",
30 "rocket":"Falcon 9 Block 5","mission":"Starlink Group 15-25","missionType":"Communications","missionDescription":null,"orbit":"LEO",
31 "pad":"Space Launch Complex 4E","location":"Vandenberg SFB, CA, USA","padLatitude":34.632,"padLongitude":-120.611,
32 "elements":[{"OBJECT_NAME":"Starlink train (Group 15-25)","NORAD_CAT_ID":66001,"EPOCH":"2026-10-18T22:10:01.123456","MEAN_MOTION":15.91,
33 "ECCENTRICITY":0.0001,"INCLINATION":70.0,"RA_OF_ASC_NODE":120.5,"ARG_OF_PERICENTER":90.1,"MEAN_ANOMALY":270.2,"BSTAR":0.0012}],
34 "elementsSource":"gp"}]}
35 """.utf8)
36 let list = try LaunchFeed.decode(data)
37 let launch = try #require(list.recent?.first)
38 #expect(launch.elements?.first?.OBJECT_NAME == "Starlink train (Group 15-25)")
39 #expect(launch.elementsSource == "gp")
21} 40}
Packages/MoonriseKit/Tests/SkyEngineTests/LaunchPassTests.swift added +26
@@ -0,0 +1,26 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5// CelesTrak GP for the ISS, 2026-10-18.
6private let iss = OrbitalElements(name: "ISS (ZARYA)", norad: 25544, epoch: "2026-10-18T03:28:15.076416",
7 meanMotion: 15.48918166, eccentricity: 0.000678, inclination: 51.6328,
8 raan: 51.6259, argPerigee: 277.7856, meanAnomaly: 82.2363, bstar: 0.00014456)
9
10@Test func visiblePassesStartAtEachEpoch() throws {
11 let epoch = try #require(iss.epochDate)
12 let passes = SkyEngine.visiblePasses(of: [iss], from: epoch.addingTimeInterval(-86_400), observer: chicago)
13 #expect(!passes.isEmpty)
14 #expect(passes.allSatisfy { $0.visible && $0.rise >= epoch && $0.rise <= epoch.addingTimeInterval(72 * 3600) })
15}
16
17@Test func visiblePassesMergeSetsInTimeOrder() throws {
18 var other = iss
19 other.OBJECT_NAME = "FALCON 9 R/B"
20 other.MEAN_ANOMALY += 40
21 let start = try #require(iss.epochDate)
22 let one = SkyEngine.visiblePasses(of: [iss], from: start, observer: chicago)
23 let both = SkyEngine.visiblePasses(of: [iss, other], from: start, observer: chicago)
24 #expect(both.count > one.count)
25 #expect(both.map(\.rise) == both.map(\.rise).sorted())
26}
server/migrations/0004_launch_designator.sql added +2
@@ -0,0 +1,2 @@
1-- International designator from Launch Library 2 (e.g. 2026-232), set after liftoff.
2ALTER TABLE launches ADD COLUMN launch_designator TEXT;
server/src/celestrak.ts +136 −22
@@ -22,42 +22,156 @@ export interface OMM {
22 22
23const KEYS: (keyof OMM)[] = ["OBJECT_NAME", "NORAD_CAT_ID", "EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION", "RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR"]; 23const KEYS: (keyof OMM)[] = ["OBJECT_NAME", "NORAD_CAT_ID", "EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION", "RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR"];
24 24
25export function pick(rows: Record<string, unknown>[]): OMM[] { 25export type Row = Record<string, unknown>;
26
27export function pick(rows: Row[]): OMM[] {
26 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM); 28 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM);
27} 29}
28 30
29/** 31/** Objects grouped by launch, keyed by international designator ("2026-232"). */
30 * One element set per recent Starlink launch still low enough to be seen as a train, 32export function byDesignator(rows: Row[]): Map<string, Row[]> {
31 * the satellite with the median mean motion, named "Starlink train (2026-232)". 33 const groups = new Map<string, Row[]>();
32 */
33export function starlinkTrains(rows: Record<string, unknown>[]): OMM[] {
34 const groups = new Map<string, Record<string, unknown>[]>();
35 for (const r of rows) { 34 for (const r of rows) {
36 if (!String(r.OBJECT_NAME).startsWith("STARLINK")) continue;
37 const launch = String(r.OBJECT_ID).slice(0, 8); 35 const launch = String(r.OBJECT_ID).slice(0, 8);
38 groups.set(launch, [...(groups.get(launch) ?? []), r]); 36 groups.set(launch, [...(groups.get(launch) ?? []), r]);
39 } 37 }
38 return groups;
39}
40
41const isStarlink = (r: Row) => String(r.OBJECT_NAME).startsWith("STARLINK");
42
43/** The Starlink satellite with the median mean motion, named "Starlink train (<label>)". */
44function train(rows: Row[], label: string): OMM | null {
45 const sorted = rows.filter(isStarlink).sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
46 if (!sorted.length) return null;
47 return { ...pick([sorted[Math.floor(sorted.length / 2)]])[0], OBJECT_NAME: `Starlink train (${label})` };
48}
49
50/** One element set per launch, keyed by label, whose Starlink satellites are still low enough to be seen as a train. */
51export function starlinkTrains(groups: Map<string, Row[]>): OMM[] {
40 const out: OMM[] = []; 52 const out: OMM[] = [];
41 for (const [launch, sats] of groups) { 53 for (const [label, rows] of groups) {
42 const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION)); 54 const t = train(rows, label);
43 const median = sorted[Math.floor(sorted.length / 2)]; 55 if (t && t.MEAN_MOTION > TRAIN_MEAN_MOTION) out.push(t);
44 if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue;
45 out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` });
46 } 56 }
47 return out; 57 return out;
48} 58}
49 59
50async function group(name: string): Promise<Record<string, unknown>[]> { 60// SupGP: pre-launch and post-deployment Starlink sets from SpaceX data. OBJECT_ID and
51 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, { 61// NORAD_CAT_ID are placeholders (a pre-launch OBJECT_ID can be a designator already given to
52 headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" }, 62// another launch), so SupGP sets are tied to launches by file name and epoch only.
53 }); 63
64/** "Starlink Group 15-15" → "starlink-g15-15"; null for other missions. */
65export function supgpFile(mission: string | null): string | null {
66 const m = mission?.match(/^Starlink Group (\d+)-(\d+)$/);
67 return m ? `starlink-g${m[1]}-${m[2]}` : null;
68}
69
70/** An unknown FILE is answered with HTTP 200 and plain text; anything but complete element sets is no data. */
71export function parseSupGP(text: string): Row[] {
72 let rows: unknown;
73 try {
74 rows = JSON.parse(text);
75 } catch {
76 return [];
77 }
78 return Array.isArray(rows) ? rows.filter(complete) : [];
79}
80
81function complete(r: unknown): r is Row {
82 if (typeof r !== "object" || r === null) return false;
83 const o = r as Row;
84 return typeof o.OBJECT_NAME === "string" && typeof o.OBJECT_ID === "string" && typeof o.EPOCH === "string" && !Number.isNaN(epoch(o)) &&
85 KEYS.every((k) => k === "OBJECT_NAME" || k === "EPOCH" || (typeof o[k] === "number" && Number.isFinite(o[k])));
86}
87
88const epoch = (r: Row) => Date.parse(`${r.EPOCH}Z`);
89
90/**
91 * Starlink deployment is one to two hours after liftoff (Group 15-15: 1 h 57 min): before launch, sets must be epoched
92 * 0–3 h after NET, which rejects a file left over from an earlier time; after launch, any time after NET.
93 */
94export function fitsLaunch(rows: Row[], net: string, now: Date): Row[] {
95 const t = Date.parse(net);
96 return rows.filter((r) => epoch(r) >= t && (t < now.getTime() || epoch(r) <= t + 3 * 3600_000));
97}
98
99export interface LaunchRef {
100 id: string;
101 mission: string | null;
102 provider: string;
103 net: string;
104 designator: string | null;
105}
106
107/** SpaceX Starlink launches from 3 days ago to 48 h ahead, nearest first, at most 4 (one SupGP request each). */
108export function supgpLaunches(launches: LaunchRef[], now: Date): LaunchRef[] {
109 const from = now.getTime() - 3 * 86400_000, to = now.getTime() + 48 * 3600_000;
110 return launches
111 .filter((l) => l.provider === "SpaceX" && supgpFile(l.mission) && Date.parse(l.net) >= from && Date.parse(l.net) <= to)
112 .sort((a, b) => Math.abs(Date.parse(a.net) - now.getTime()) - Math.abs(Date.parse(b.net) - now.getTime()))
113 .slice(0, 4);
114}
115
116const newest = (rows: Row[]) => Math.max(...rows.map(epoch));
117
118/** A Starlink train, then rocket bodies and other objects (no debris), at most 8. */
119function launchElements(rows: Row[], label: string): OMM[] {
120 const others = rows
121 .filter((r) => !isStarlink(r) && !String(r.OBJECT_NAME).includes(" DEB"))
122 .sort((a, b) => Number(String(b.OBJECT_NAME).includes("R/B")) - Number(String(a.OBJECT_NAME).includes("R/B")) || String(a.OBJECT_ID).localeCompare(String(b.OBJECT_ID)));
123 const t = train(rows, label);
124 return [...(t ? [t] : []), ...pick(others)].slice(0, 8);
125}
126
127export interface LaunchElements {
128 /** "gp": CelesTrak GP matched by designator; "supgp": SpaceX data via CelesTrak SupGP. */
129 source: "gp" | "supgp";
130 elements: OMM[];
131}
132
133/**
134 * The satellite list (visual group plus trains, labelled with the mission name where known) and element sets
135 * per launch. For each launch the GP objects with its designator or its SupGP sets are used, whichever has the
136 * newer epoch. A SupGP train joins the satellite list only once its epoch (predicted deployment) has passed,
137 * since clients propagate that list from now.
138 */
139export function compose(visual: Row[], recent: Row[], launches: LaunchRef[], supgp: Record<string, Row[]>, now: Date): { satellites: OMM[]; launches: Record<string, LaunchElements> } {
140 const gp = byDesignator(recent);
141 const groups = new Map(gp);
142 const out: Record<string, LaunchElements> = {};
143 for (const l of launches) {
144 const g = l.designator ? gp.get(l.designator) : undefined;
145 const s = supgp[l.id]?.length ? supgp[l.id] : undefined;
146 const source = s && (!g || newest(s) > newest(g)) ? "supgp" : "gp";
147 const rows = source === "supgp" ? s : g;
148 if (!rows) continue;
149 const label = l.mission?.startsWith("Starlink ") ? l.mission.slice(9) : (l.mission ?? l.designator ?? l.id);
150 if (l.designator) groups.delete(l.designator);
151 if (source === "gp" || newest(rows) <= now.getTime()) groups.set(label, rows);
152 out[l.id] = { source, elements: launchElements(rows, label) };
153 }
154 return { satellites: [...pick(visual), ...starlinkTrains(groups)], launches: out };
155}
156
157const HEADERS = { "user-agent": "moonrise-server (sh.krz.moonrise)" };
158
159async function group(name: string): Promise<Row[]> {
160 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, { headers: HEADERS });
54 if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`); 161 if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`);
55 return (await res.json()) as Record<string, unknown>[]; 162 return (await res.json()) as Row[];
56} 163}
57 164
58/** The visual group (about 150 bright objects) and Starlink trains; throws on the first non-200 so nothing more is requested. */ 165/** The visual group (about 150 bright objects) and the last 30 days' launches; throws on the first non-200 so nothing more is requested. */
59export async function fetchElements(): Promise<OMM[]> { 166export async function fetchGroups(): Promise<{ visual: Row[]; recent: Row[] }> {
60 const visual = pick(await group("visual")); 167 const visual = await group("visual");
61 const recent = await group("last-30-days"); 168 const recent = await group("last-30-days");
62 return [...visual, ...starlinkTrains(recent)]; 169 return { visual, recent };
170}
171
172/** One SupGP file; throws on a non-200 so the caller stops. */
173export async function fetchSupGP(file: string): Promise<Row[]> {
174 const res = await fetch(`https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=${file}&FORMAT=JSON`, { headers: HEADERS });
175 if (res.status !== 200) throw new Error(`CelesTrak SupGP ${res.status} for ${file}`);
176 return parseSupGP(await res.text());
63} 177}
server/src/index.ts +65 −18
@@ -1,8 +1,8 @@
1import { send, type ApnsConfig } from "./apns"; 1import { send, type ApnsConfig } from "./apns";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; 2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchElements } from "./celestrak"; 3import { compose, fetchGroups, fetchSupGP, fitsLaunch, supgpFile, supgpLaunches, type LaunchElements, type LaunchRef, type Row } from "./celestrak";
4import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live"; 4import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live";
5import { fetchUpcoming, type Launch } from "./ll2"; 5import { fetchLaunches, PREVIOUS_URL, UPCOMING_URL, type Launch } from "./ll2";
6import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news"; 6import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news";
7import { PRIVACY_HTML } from "./privacy"; 7import { PRIVACY_HTML } from "./privacy";
8import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; 8import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
@@ -28,6 +28,8 @@ interface DeviceRequest {
28 28
29const LAUNCH_CRON = "*/10 * * * *"; 29const LAUNCH_CRON = "*/10 * * * *";
30const SATELLITE_CRON = "7 */2 * * *"; 30const SATELLITE_CRON = "7 */2 * * *";
31/** Launches are kept this long after NET, so a launch page and its first pass outlive liftoff. */
32const RETENTION_MS = 14 * 86400_000;
31 33
32const json = (body: unknown, status = 200, maxAge?: number) => 34const json = (body: unknown, status = 200, maxAge?: number) =>
33 Response.json(body, { status, headers: maxAge && status === 200 ? { "cache-control": `public, max-age=${maxAge}` } : {} }); 35 Response.json(body, { status, headers: maxAge && status === 200 ? { "cache-control": `public, max-age=${maxAge}` } : {} });
@@ -97,7 +99,7 @@ export default {
97 99
98 async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { 100 async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
99 const now = new Date(controller.scheduledTime); 101 const now = new Date(controller.scheduledTime);
100 // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour. 102 // Upcoming launches every 10 minutes and previous launches hourly: 7 of the 15 anonymous Launch Library 2 requests per hour.
101 if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)])); 103 if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)]));
102 else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now)); 104 else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now));
103 else ctx.waitUntil(pollSpaceWeather(env, now)); 105 else ctx.waitUntil(pollSpaceWeather(env, now));
@@ -154,15 +156,22 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
154async function launches(env: Env, now: Date): Promise<Response> { 156async function launches(env: Env, now: Date): Promise<Response> {
155 const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); 157 const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
156 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); 158 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>();
159 const recent = await env.DB.prepare("SELECT * FROM launches WHERE net < ?1 AND net >= ?2 ORDER BY net DESC LIMIT 20")
160 .bind(since, new Date(now.getTime() - RETENTION_MS).toISOString()).all<Record<string, unknown>>();
157 const fetched = await env.DB.prepare("SELECT fetched_at FROM feed_snapshots WHERE feed = 'll2' AND ok = 1").first<{ fetched_at: string }>(); 161 const fetched = await env.DB.prepare("SELECT fetched_at FROM feed_snapshots WHERE feed = 'll2' AND ok = 1").first<{ fetched_at: string }>();
162 const els = await env.DB.prepare("SELECT body FROM feed_snapshots WHERE feed = 'launch-elements' AND ok = 1").first<{ body: string }>();
163 const elements: Record<string, LaunchElements> = els ? JSON.parse(els.body) : {};
164 const shape = (r: Record<string, unknown>) => ({
165 id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end,
166 status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket,
167 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
168 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
169 elements: elements[String(r.id)]?.elements ?? [], elementsSource: elements[String(r.id)]?.source ?? null,
170 });
158 return json({ 171 return json({
159 fetchedAt: fetched?.fetched_at ?? null, 172 fetchedAt: fetched?.fetched_at ?? null,
160 launches: rows.results.map((r) => ({ 173 launches: rows.results.map(shape),
161 id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end, 174 recent: recent.results.map(shape),
162 status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket,
163 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
164 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
165 })),
166 }, 200, MAX_AGE.launches); 175 }, 200, MAX_AGE.launches);
167} 176}
168 177
@@ -170,34 +179,72 @@ async function launches(env: Env, now: Date): Promise<Response> {
170async function pollLaunches(env: Env, now: Date): Promise<void> { 179async function pollLaunches(env: Env, now: Date): Promise<void> {
171 let list: Launch[]; 180 let list: Launch[];
172 try { 181 try {
173 list = await fetchUpcoming(env.LL2_API_KEY); 182 list = await fetchLaunches(UPCOMING_URL, env.LL2_API_KEY);
174 } catch (e) { 183 } catch (e) {
175 console.error("ll2", String(e)); 184 console.error("ll2", String(e));
176 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run(); 185 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
177 return; 186 return;
178 } 187 }
188 // Previous launches on the first run of each hour: designators and outcomes arrive after a launch leaves the upcoming list.
189 if (now.getUTCMinutes() < 10) {
190 try {
191 list = [...list, ...(await fetchLaunches(PREVIOUS_URL, env.LL2_API_KEY))];
192 } catch (e) {
193 console.error("ll2 previous", String(e));
194 }
195 }
179 const upsert = env.DB.prepare( 196 const upsert = env.DB.prepare(
180 `INSERT INTO launches (id, name, net, window_start, window_end, status, status_name, provider, rocket, mission, mission_type, mission_description, orbit, pad, location, pad_latitude, pad_longitude, last_updated) 197 `INSERT INTO launches (id, name, net, window_start, window_end, status, status_name, provider, rocket, mission, mission_type, mission_description, orbit, pad, location, pad_latitude, pad_longitude, last_updated, launch_designator)
181 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) 198 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
182 ON CONFLICT (id) DO UPDATE SET name = ?2, net = ?3, window_start = ?4, window_end = ?5, status = ?6, status_name = ?7, provider = ?8, rocket = ?9, 199 ON CONFLICT (id) DO UPDATE SET name = ?2, net = ?3, window_start = ?4, window_end = ?5, status = ?6, status_name = ?7, provider = ?8, rocket = ?9,
183 mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18`, 200 mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18,
201 launch_designator = COALESCE(?19, launch_designator)`,
184 ); 202 );
185 await env.DB.batch([ 203 await env.DB.batch([
186 ...list.map((l) => upsert.bind(l.id, l.name, l.net, l.windowStart, l.windowEnd, l.status, l.statusName, l.provider, l.rocket, l.mission, l.missionType, l.missionDescription, l.orbit, l.pad, l.location, l.padLatitude, l.padLongitude, l.lastUpdated)), 204 ...list.map((l) => upsert.bind(l.id, l.name, l.net, l.windowStart, l.windowEnd, l.status, l.statusName, l.provider, l.rocket, l.mission, l.missionType, l.missionDescription, l.orbit, l.pad, l.location, l.padLatitude, l.padLongitude, l.lastUpdated, l.designator)),
187 env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - 7 * 86400_000).toISOString()), 205 env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - RETENTION_MS).toISOString()),
188 env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 1) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1").bind(now.toISOString()), 206 env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 1) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1").bind(now.toISOString()),
189 ]); 207 ]);
190} 208}
191 209
192async function pollSatellites(env: Env, now: Date): Promise<void> { 210async function pollSatellites(env: Env, now: Date): Promise<void> {
211 let groups: { visual: Row[]; recent: Row[] };
193 try { 212 try {
194 const elements = await fetchElements(); 213 groups = await fetchGroups();
195 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('celestrak', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2")
196 .bind(now.toISOString(), JSON.stringify(elements)).run();
197 } catch (e) { 214 } catch (e) {
198 console.error("celestrak", String(e)); 215 console.error("celestrak", String(e));
199 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('celestrak:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run(); 216 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('celestrak:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
217 return;
218 }
219 const launches = await env.DB.prepare("SELECT id, mission, provider, net, launch_designator AS designator FROM launches WHERE net >= ?1")
220 .bind(new Date(now.getTime() - RETENTION_MS).toISOString()).all<LaunchRef>();
221 const supgp = await pollSupGP(env, launches.results, now);
222 const fitting = Object.fromEntries(launches.results.map((l) => [l.id, fitsLaunch(supgp[l.id] ?? [], l.net, now)]));
223 const out = compose(groups.visual, groups.recent, launches.results, fitting, now);
224 const save = env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES (?1, ?2, 1, ?3) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?2, ok = 1, body = ?3");
225 await env.DB.batch([
226 save.bind("celestrak", now.toISOString(), JSON.stringify(out.satellites)),
227 save.bind("launch-elements", now.toISOString(), JSON.stringify(out.launches)),
228 ]);
229}
230
231/**
232 * SupGP sets by launch id, fetched after the GP groups and kept in their own snapshot: on any failure no more
233 * requests are made and the last good sets are used, so the GP data is never lost to a SupGP problem.
234 */
235async function pollSupGP(env: Env, launches: LaunchRef[], now: Date): Promise<Record<string, Row[]>> {
236 const sets: Record<string, Row[]> = {};
237 try {
238 for (const l of supgpLaunches(launches, now)) sets[l.id] = await fetchSupGP(supgpFile(l.mission)!);
239 } catch (e) {
240 console.error("supgp", String(e));
241 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('supgp:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
242 const last = await env.DB.prepare("SELECT body FROM feed_snapshots WHERE feed = 'supgp' AND ok = 1").first<{ body: string }>();
243 return last ? JSON.parse(last.body) : {};
200 } 244 }
245 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('supgp', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2")
246 .bind(now.toISOString(), JSON.stringify(sets)).run();
247 return sets;
201} 248}
202 249
203async function pollSpaceWeather(env: Env, now: Date): Promise<void> { 250async function pollSpaceWeather(env: Env, now: Date): Promise<void> {
server/src/ll2.ts +8 −2
@@ -2,6 +2,8 @@
2// Images are not used: their licences are often "Unknown". 2// Images are not used: their licences are often "Unknown".
3 3
4export const UPCOMING_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=20&mode=normal"; 4export const UPCOMING_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=20&mode=normal";
5/** Recent launches, for the international designator, which is assigned only after liftoff. */
6export const PREVIOUS_URL = "https://ll.thespacedevs.com/2.3.0/launches/previous/?limit=10&mode=normal";
5 7
6export interface Launch { 8export interface Launch {
7 id: string; 9 id: string;
@@ -22,6 +24,8 @@ export interface Launch {
22 padLatitude: number | null; 24 padLatitude: number | null;
23 padLongitude: number | null; 25 padLongitude: number | null;
24 lastUpdated: string; 26 lastUpdated: string;
27 /** International designator, e.g. "2026-232"; null before launch. */
28 designator: string | null;
25} 29}
26 30
27interface Raw { 31interface Raw {
@@ -31,6 +35,7 @@ interface Raw {
31 window_start?: string | null; 35 window_start?: string | null;
32 window_end?: string | null; 36 window_end?: string | null;
33 last_updated: string; 37 last_updated: string;
38 launch_designator?: string | null;
34 status: { abbrev: string; name: string }; 39 status: { abbrev: string; name: string };
35 launch_service_provider: { name: string }; 40 launch_service_provider: { name: string };
36 rocket: { configuration: { full_name?: string; name: string } }; 41 rocket: { configuration: { full_name?: string; name: string } };
@@ -60,14 +65,15 @@ export function normalize(r: Raw): Launch {
60 padLatitude: num(r.pad.latitude), 65 padLatitude: num(r.pad.latitude),
61 padLongitude: num(r.pad.longitude), 66 padLongitude: num(r.pad.longitude),
62 lastUpdated: r.last_updated, 67 lastUpdated: r.last_updated,
68 designator: r.launch_designator ?? null,
63 }; 69 };
64} 70}
65 71
66/** `apiKey` lifts the anonymous per-IP limit, which Cloudflare's shared egress IPs often exhaust (HTTP 429). */ 72/** `apiKey` lifts the anonymous per-IP limit, which Cloudflare's shared egress IPs often exhaust (HTTP 429). */
67export async function fetchUpcoming(apiKey?: string): Promise<Launch[]> { 73export async function fetchLaunches(url: string, apiKey?: string): Promise<Launch[]> {
68 const headers: Record<string, string> = { "user-agent": "moonrise-server (sh.krz.moonrise)" }; 74 const headers: Record<string, string> = { "user-agent": "moonrise-server (sh.krz.moonrise)" };
69 if (apiKey) headers.authorization = `Token ${apiKey}`; 75 if (apiKey) headers.authorization = `Token ${apiKey}`;
70 const res = await fetch(UPCOMING_URL, { headers }); 76 const res = await fetch(url, { headers });
71 if (!res.ok) throw new Error(`LL2 ${res.status}`); 77 if (!res.ok) throw new Error(`LL2 ${res.status}`);
72 const body = (await res.json()) as { results: Raw[] }; 78 const body = (await res.json()) as { results: Raw[] };
73 return body.results.map(normalize); 79 return body.results.map(normalize);
server/test/celestrak.test.ts +109 −3
@@ -1,6 +1,6 @@
1import assert from "node:assert/strict"; 1import assert from "node:assert/strict";
2import { test } from "node:test"; 2import { test } from "node:test";
3import { pick, starlinkTrains } from "../src/celestrak.ts"; 3import { byDesignator, compose, fitsLaunch, parseSupGP, pick, starlinkTrains, supgpFile, supgpLaunches } from "../src/celestrak.ts";
4 4
5test("keeps only the SGP4 fields", () => { 5test("keeps only the SGP4 fields", () => {
6 // CelesTrak GP JSON for the ISS, 2026-10-18. 6 // CelesTrak GP JSON for the ISS, 2026-10-18.
@@ -20,12 +20,118 @@ test("one Starlink train per low recent launch, median satellite", () => {
20 OBJECT_NAME: `STARLINK-${n}`, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: "2026-10-18T00:00:00", MEAN_MOTION: mm, 20 OBJECT_NAME: `STARLINK-${n}`, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: "2026-10-18T00:00:00", MEAN_MOTION: mm,
21 ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001, 21 ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001,
22 }); 22 });
23 const trains = starlinkTrains([ 23 const trains = starlinkTrains(byDesignator([
24 sat("2026-232A", 1, 15.9), sat("2026-232B", 2, 16.0), sat("2026-232C", 3, 15.95), 24 sat("2026-232A", 1, 15.9), sat("2026-232B", 2, 16.0), sat("2026-232C", 3, 15.95),
25 sat("2026-180A", 4, 15.06), sat("2026-180B", 5, 15.07), 25 sat("2026-180A", 4, 15.06), sat("2026-180B", 5, 15.07),
26 { ...sat("2026-232D", 6, 16.1), OBJECT_NAME: "FALCON 9 R/B" }, 26 { ...sat("2026-232D", 6, 16.1), OBJECT_NAME: "FALCON 9 R/B" },
27 ]); 27 ]));
28 assert.equal(trains.length, 1); 28 assert.equal(trains.length, 1);
29 assert.equal(trains[0].OBJECT_NAME, "Starlink train (2026-232)"); 29 assert.equal(trains[0].OBJECT_NAME, "Starlink train (2026-232)");
30 assert.equal(trains[0].NORAD_CAT_ID, 3); 30 assert.equal(trains[0].NORAD_CAT_ID, 3);
31}); 31});
32
33// sup-gp.php?FILE=starlink-g15-15&FORMAT=JSON on 2026-10-19 (trimmed). Its OBJECT_ID is a guess:
34// 2026-235 went to USSF-481 on 2026-10-16.
35const SUPGP = `[{"OBJECT_NAME":"STARLINK-G15-15 STACK","OBJECT_ID":"2026-235A","EPOCH":"2026-10-21T00:11:38.559984","MEAN_MOTION":16.05876681,"ECCENTRICITY":0.0009517,"INCLINATION":70.007,"RA_OF_ASC_NODE":92.2113,"ARG_OF_PERICENTER":261.8925,"MEAN_ANOMALY":112.7386,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"C","NORAD_CAT_ID":72000,"ELEMENT_SET_NO":0,"REV_AT_EPOCH":1,"BSTAR":0.00011238,"MEAN_MOTION_DOT":0.00082246,"MEAN_MOTION_DDOT":0,"RMS":"0.096","DATA_SOURCE":"SpaceX-SV"},
36{"OBJECT_NAME":"STARLINK-G15-15 SINGLE","OBJECT_ID":"2026-235B","EPOCH":"2026-10-21T00:11:38.559984","MEAN_MOTION":16.05877557,"ECCENTRICITY":0.0009499,"INCLINATION":70.0069,"RA_OF_ASC_NODE":92.2113,"ARG_OF_PERICENTER":263.1103,"MEAN_ANOMALY":111.5203,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"C","NORAD_CAT_ID":72001,"ELEMENT_SET_NO":0,"REV_AT_EPOCH":1,"BSTAR":0.0016439,"MEAN_MOTION_DOT":0.012324,"MEAN_MOTION_DDOT":0,"RMS":"0.110","DATA_SOURCE":"SpaceX-SV"}]`;
37
38test("maps Starlink mission names to SupGP files", () => {
39 assert.equal(supgpFile("Starlink Group 15-15"), "starlink-g15-15");
40 assert.equal(supgpFile("Starlink Group 6-100"), "starlink-g6-100");
41 assert.equal(supgpFile("Transporter 18 (Dedicated SSO Rideshare)"), null);
42 assert.equal(supgpFile(null), null);
43});
44
45test("SupGP misses and malformed rows are no data", () => {
46 assert.equal(parseSupGP(SUPGP).length, 2);
47 // An unknown FILE comes back as HTTP 200 with plain text.
48 assert.deepEqual(parseSupGP("Invalid query: \"starlink-g99-99\" not found."), []);
49 assert.deepEqual(parseSupGP("{}"), []);
50 assert.deepEqual(parseSupGP("[{"), []);
51 const rows = JSON.parse(SUPGP);
52 delete rows[1].BSTAR;
53 assert.equal(parseSupGP(JSON.stringify(rows)).length, 1);
54 assert.deepEqual(parseSupGP(JSON.stringify([{ ...rows[0], MEAN_MOTION: "16" }])), []);
55});
56
57test("SupGP sets must fit the launch time", () => {
58 const rows = parseSupGP(SUPGP);
59 const before = new Date("2026-10-19T12:00:00Z");
60 // LL2 NET for Group 15-15: deployment 1 h 57 min later.
61 assert.equal(fitsLaunch(rows, "2026-10-20T22:14:00Z", before).length, 2);
62 // Slipped a day: the file is for the old time.
63 assert.equal(fitsLaunch(rows, "2026-10-21T23:10:00Z", before).length, 0);
64 // Epoch four hours after a liftoff still to come: not this opportunity.
65 assert.equal(fitsLaunch(rows, "2026-10-20T20:00:00Z", before).length, 0);
66 // After liftoff, later post-deployment sets are fine.
67 assert.equal(fitsLaunch(rows, "2026-10-20T20:00:00Z", new Date("2026-10-21T06:00:00Z")).length, 2);
68});
69
70const launch = (id: string, mission: string | null, net: string, designator: string | null = null, provider = "SpaceX") => ({ id, mission, provider, net, designator });
71
72test("SupGP is fetched for at most four nearby Starlink launches, nearest first", () => {
73 const now = new Date("2026-10-19T12:00:00Z");
74 const picked = supgpLaunches([
75 launch("far", "Starlink Group 1-1", "2026-10-22T12:00:00Z"),
76 launch("old", "Starlink Group 1-2", "2026-10-15T12:00:00Z"),
77 launch("a", "Starlink Group 1-3", "2026-10-19T14:00:00Z"),
78 launch("b", "Starlink Group 1-4", "2026-10-18T12:00:00Z"),
79 launch("c", "Starlink Group 1-5", "2026-10-20T12:00:00Z"),
80 launch("d", "Starlink Group 1-6", "2026-10-17T00:00:00Z"),
81 launch("e", "Starlink Group 1-7", "2026-10-16T13:00:00Z"),
82 launch("ussf", "USSF-481", "2026-10-19T13:00:00Z"),
83 launch("other", "Starlink Group 1-8", "2026-10-19T13:00:00Z", null, "Rocket Lab"),
84 ], now);
85 assert.deepEqual(picked.map((l) => l.id), ["a", "b", "c", "d"]);
86});
87
88const gp = (id: string, name: string, n: number, epoch = "2026-10-18T00:00:00", mm = 15.9) => ({
89 OBJECT_NAME: name, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: epoch, MEAN_MOTION: mm,
90 ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001,
91});
92
93test("labels trains with the mission and keeps SupGP apart from a designator it guessed", () => {
94 const recent = [
95 gp("2026-232A", "STARLINK-1", 1), gp("2026-232B", "STARLINK-2", 2), gp("2026-232C", "STARLINK-3", 3),
96 gp("2026-235A", "USA 600", 10), gp("2026-235B", "FALCON 9 R/B", 11),
97 ];
98 const launches = [
99 launch("g1525", "Starlink Group 15-25", "2026-10-13T00:28:11Z", "2026-232"),
100 launch("ussf", "USSF-481", "2026-10-16T00:01:05Z", "2026-235"),
101 launch("g1515", "Starlink Group 15-15", "2026-10-20T23:10:00Z"),
102 ];
103 const out = compose([gp("1998-067A", "ISS (ZARYA)", 25544)], recent, launches, { g1515: parseSupGP(SUPGP) }, new Date("2026-10-21T06:00:00Z"));
104 assert.deepEqual(out.satellites.map((s) => s.OBJECT_NAME), ["ISS (ZARYA)", "Starlink train (Group 15-25)", "Starlink train (Group 15-15)"]);
105 assert.deepEqual(out.launches.g1525.elements.map((e) => e.OBJECT_NAME), ["Starlink train (Group 15-25)"]);
106 assert.equal(out.launches.g1525.source, "gp");
107 assert.deepEqual(out.launches.ussf.elements.map((e) => e.OBJECT_NAME), ["FALCON 9 R/B", "USA 600"]);
108 assert.equal(out.launches.g1515.source, "supgp");
109 assert.equal(out.launches.g1515.elements[0].NORAD_CAT_ID, 72001);
110 // Before its predicted deployment a train is on the launch only: the satellite list is propagated from now.
111 const early = compose([], recent, launches, { g1515: parseSupGP(SUPGP) }, new Date("2026-10-19T12:00:00Z"));
112 assert.deepEqual(early.satellites.map((s) => s.OBJECT_NAME), ["Starlink train (Group 15-25)"]);
113 assert.equal(early.launches.g1515.source, "supgp");
114});
115
116test("the newer of GP and SupGP wins for a launch", () => {
117 const launches = [launch("g1525", "Starlink Group 15-25", "2026-10-13T00:28:11Z", "2026-232")];
118 const recent = [gp("2026-232A", "STARLINK-1", 1, "2026-10-18T00:00:00")];
119 const older = [gp("2026-999A", "STARLINK-G15-25 STACK", 72000, "2026-10-13T01:30:00")];
120 const newer = [gp("2026-999A", "STARLINK-G15-25 STACK", 72000, "2026-10-18T06:00:00")];
121 assert.equal(compose([], recent, launches, { g1525: older }, new Date("2026-10-19T12:00:00Z")).launches.g1525.source, "gp");
122 const out = compose([], recent, launches, { g1525: newer }, new Date("2026-10-19T12:00:00Z"));
123 assert.equal(out.launches.g1525.source, "supgp");
124 assert.equal(out.satellites.length, 1);
125});
126
127test("other launches list rocket bodies first, skip debris and stop at eight", () => {
128 const recent = [
129 ...Array.from({ length: 12 }, (_, i) => gp(`2026-227${String.fromCharCode(65 + i)}`, `SAT ${i}`, 100 + i)),
130 gp("2026-227M", "FALCON 9 R/B", 200), gp("2026-227N", "FALCON 9 DEB", 201),
131 ];
132 const out = compose([], recent, [launch("t18", "Transporter 18", "2026-10-01T18:32:00Z", "2026-227")], {}, new Date("2026-10-19T12:00:00Z"));
133 const names = out.launches.t18.elements.map((e) => e.OBJECT_NAME);
134 assert.equal(names.length, 8);
135 assert.equal(names[0], "FALCON 9 R/B");
136 assert.ok(!names.includes("FALCON 9 DEB"));
137});
server/test/ll2.test.ts +26 −1
@@ -1,6 +1,6 @@
1import assert from "node:assert/strict"; 1import assert from "node:assert/strict";
2import { test } from "node:test"; 2import { test } from "node:test";
3import { normalize } from "../src/ll2.ts"; 3import { normalize, PREVIOUS_URL } from "../src/ll2.ts";
4 4
5// Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17. 5// Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17.
6const raw = { 6const raw = {
@@ -31,3 +31,28 @@ test("tolerates a missing mission", () => {
31 assert.equal(l.mission, null); 31 assert.equal(l.mission, null);
32 assert.equal(l.orbit, null); 32 assert.equal(l.orbit, null);
33}); 33});
34
35// Trimmed from /2.3.0/launches/previous/?mode=normal on 2026-10-19.
36const launched = {
37 id: "97c6f5e1-299e-4652-b72d-9618ac047076",
38 name: "Falcon 9 Block 5 | Starlink Group 15-25",
39 net: "2026-10-13T00:28:11Z",
40 window_start: "2026-10-12T23:00:00Z",
41 window_end: "2026-10-13T03:00:00Z",
42 last_updated: "2026-10-13T10:10:50Z",
43 launch_designator: "2026-232",
44 status: { name: "Launch Successful", abbrev: "Success" },
45 launch_service_provider: { name: "SpaceX" },
46 rocket: { configuration: { name: "Falcon 9", full_name: "Falcon 9 Block 5" } },
47 mission: { name: "Starlink Group 15-25", type: "Communications", orbit: { abbrev: "LEO" } },
48 pad: { name: "Space Launch Complex 4E", latitude: 34.632, longitude: -120.611, location: { name: "Vandenberg SFB, CA, USA" } },
49};
50
51test("keeps the international designator once launched", () => {
52 assert.equal(normalize(launched).designator, "2026-232");
53 assert.equal(normalize(raw).designator, null);
54});
55
56test("previous launches are fetched ten at a time", () => {
57 assert.match(PREVIOUS_URL, /\/launches\/previous\/\?limit=10&mode=normal$/);
58});