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 a64782b360

a64782b360c963c073802d6a243f01aa1ae8a790

parent: ddd82475d9

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 02:02 UTC

Launch payload first pass and Starlink train labels (!58)

Stages (b) and (c) of the launch visibility design: a launch's first visible pass and labelled Starlink trains.

Server
- `migrations/0004_launch_designator.sql` adds `launches.launch_designator` (local only; not applied remotely).
- LL2 `launches/previous/?limit=10` on the first `*/10` run of each hour. Upserts keep an existing designator when the upcoming list sends null.
- LL2 requests per hour: 6 upcoming + 1 previous = 7 of the 15 anonymous limit. No other cron calls LL2 (news uses SNAPI).
- Launches are kept 14 days after NET (was 7). `/v1/launches` adds `recent` (NET from 14 days ago to now − 6 h, latest first, 20 max), and each launch gets `elements` (OMM) and `elementsSource` (`gp` or `supgp`).
- Satellite cron, per 2 h: the 2 GP groups, then up to 4 SupGP files (`starlink-gX-Y`) for SpaceX "Starlink Group X-Y" launches from 3 days ago to 48 h ahead, nearest first. Any non-200 stops further requests (a GP failure means no SupGP request). A 200 that is not a JSON array of complete element sets counts as no data. SupGP has its own `supgp` snapshot; when it fails, the last good one is used and the GP data is unaffected. No `gp.php?INTDES` queries.
- SupGP `OBJECT_ID` is a guess: today's `starlink-g15-15` says `2026-235A`, but 2026-235 went to USSF-481. SupGP sets are therefore matched to launches by file name and epoch, never by designator. Before launch, the epoch must fall 0–3 h after NET (Group 15-15 deploys 1 h 57 min after NET); after launch, any time after NET.
- For each launch, the GP objects with its designator or its SupGP sets, whichever has the newer epoch. These become a Starlink train (median by mean motion) followed by rocket bodies and other objects, without debris, at most 8.
- Trains are named `Starlink train (Group 15-25)`. The `Starlink train` prefix is kept so 1.0.0 still treats them as featured. A SupGP train joins `/v1/satellites` only once its predicted deployment has passed, because clients propagate that list from now.

App
- Kit: `SkyEngine.visiblePasses(of:from:hours:observer:)` searches each element set from the later of start and its epoch. `Feeds` now depends on `SkyEngine` for `Launch.elements`.
- Launches tab: a Recent section, so a launch page stays reachable for 14 days after liftoff.
- Launch detail, Visibility: "First visible pass" with day, time, rise → set direction and max altitude, then up to 2 later passes of the same object. The page says "Sunlit while your sky is dark; it may still be too faint to see", adding "though new trains are often visible" for trains. The source line reads "Predicted before launch · SpaceX via CelesTrak" or "Orbit: CelesTrak". The observer is the chosen city or the last known location (`libraryObserver()`, now internal). Nothing is sent to the server.
- "Notify me 15 minutes before" schedules a local notification. It is replaced when the Launches list loads, and when the app becomes active while any launch is followed (one `/v1/launches` fetch).

Not done
- No "Train tonight" chip: the doc cites an ISS chip that does not exist in SkyScreen.
- SupGP backup files (`-b1`…`-b3`), rideshare SupGP files, and the first-seen latency log.

Not verified
- The UI was not run in a simulator. Booting another simulator failed on process limits, and the booted ones belonged to other sessions. The app builds for the iOS Simulator.
- No notifications fired.
- No `-b` backup file has been checked for whether CelesTrak updates the base file after a slip. The 3 h epoch bound can accept a base file after a same-window slip of under about 2 h.
- Local smoke test with `wrangler dev` against live LL2 and CelesTrak (3 CelesTrak requests). The previous poll stored designators. `/v1/launches` returned Group 15-15 (SupGP, pre-launch), Group 15-25 (GP train) and other launches' objects, and on-device pass computation over that response gave passes for Chicago. Remote D1 migration and deploy are not done.
- Tests: `swift test` (MoonriseKit) and `npm test` (server, 27) pass.

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,6 +1,7 @@
1import ActivityKit 1import ActivityKit
2import Feeds 2import Feeds
3import Library 3import Library
4import SkyEngine
4import SwiftUI 5import SwiftUI
5 6
6/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. 7/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset.
@@ -71,6 +72,13 @@ struct LaunchesScreen: View {
71 Button { selected = launch } label: { row(launch, index: i + 1) } 72 Button { selected = launch } label: { row(launch, index: i + 1) }
72 .buttonStyle(.plain) 73 .buttonStyle(.plain)
73 } 74 }
75 if let recent = list.recent, !recent.isEmpty {
76 Text("Recent").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 20).padding(.bottom, 4)
77 ForEach(Array(recent.enumerated()), id: \.element.id) { i, launch in
78 Button { selected = launch } label: { row(launch, index: i + 1) }
79 .buttonStyle(.plain)
80 }
81 }
74 Text("Launch data: The Space Devs, Launch Library 2.") 82 Text("Launch data: The Space Devs, Launch Library 2.")
75 .font(.caption2).dimmed(0.5).padding(.vertical, 16) 83 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
76 } else if error == nil { 84 } else if error == nil {
@@ -84,7 +92,10 @@ struct LaunchesScreen: View {
84 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return } 92 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
85 do { 93 do {
86 list = try await LaunchFeed.fetch(base: apiBase) 94 list = try await LaunchFeed.fetch(base: apiBase)
87 if let list { FeedMemo.set("launches", list) } 95 if let list {
96 FeedMemo.set("launches", list)
97 await LaunchPassNotifications.reschedule(list.launches + (list.recent ?? []))
98 }
88 error = nil 99 error = nil
89 } catch { 100 } catch {
90 self.error = "Couldn't load launches. Pull to retry." 101 self.error = "Couldn't load launches. Pull to retry."
@@ -175,6 +186,7 @@ struct LaunchDetail: View {
175 fact("Location", launch.location) 186 fact("Location", launch.location)
176 } 187 }
177 .font(.subheadline) 188 .font(.subheadline)
189 visibility.padding(.top, 8)
178 followButton.padding(.top, 8) 190 followButton.padding(.top, 8)
179 if let message { Text(message).font(.caption).dimmed(0.7) } 191 if let message { Text(message).font(.caption).dimmed(0.7) }
180 if let d = launch.missionDescription { 192 if let d = launch.missionDescription {
@@ -191,6 +203,76 @@ struct LaunchDetail: View {
191 203
192 @State private var message: String? 204 @State private var message: String?
193 @State private var following = false 205 @State private var following = false
206 @State private var passes: [SatellitePass]?
207 @State private var notifying = false
208 @State private var notifyMessage: String?
209
210 /// First visible passes over the observer, worked out on the device.
211 @ViewBuilder
212 private var visibility: some View {
213 if let elements = launch.elements, !elements.isEmpty {
214 VStack(alignment: .leading, spacing: 6) {
215 Text("Visibility").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
216 if let passes {
217 if let first = passes.first {
218 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
219 fact("First visible pass", describe(first))
220 ForEach(passes.filter { $0.name == first.name }.dropFirst().prefix(2)) { fact("Later", describe($0)) }
221 }
222 .font(.subheadline)
223 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" : "").")
224 .font(.caption).dimmed(0.7)
225 Text(source).font(.caption2).dimmed(0.5)
226 notifyButton
227 if let notifyMessage { Text(notifyMessage).font(.caption).dimmed(0.7) }
228 } else {
229 Text("No visible pass from here in the next three days.").font(.subheadline).dimmed(0.7)
230 }
231 } else {
232 ProgressView().tint(ink)
233 }
234 }
235 .task {
236 notifying = LaunchPassNotifications.followed.contains(launch.id)
237 passes = await LaunchPassNotifications.passes(of: launch, observer: libraryObserver())
238 }
239 } else if launch.net < .now {
240 Text("No orbit published for this launch yet.").font(.caption).dimmed(0.7)
241 }
242 }
243
244 private var source: String {
245 let credit = launch.elementsSource == "supgp" ? "SpaceX via CelesTrak" : "CelesTrak"
246 return launch.elementsSource == "supgp" && launch.net > .now ? "Predicted before launch · \(credit)" : "Orbit: \(credit)"
247 }
248
249 private func describe(_ p: SatellitePass) -> String {
250 p.rise.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
251 + " · \(LaunchPassNotifications.compass(p.riseAzimuth)) → \(LaunchPassNotifications.compass(p.setAzimuth)), highest \(Int(p.maxAltitude.rounded()))°"
252 }
253
254 private var notifyButton: some View {
255 Button {
256 if notifying {
257 LaunchPassNotifications.unfollow(launch)
258 notifying = false
259 notifyMessage = nil
260 } else {
261 Task {
262 notifying = await LaunchPassNotifications.follow(launch)
263 notifyMessage = notifying ? nil : "Notifications are off for Moonrise. Turn them on in the Settings app."
264 }
265 }
266 } label: {
267 Label(notifying ? "Don't notify me" : "Notify me 15 minutes before", systemImage: notifying ? "bell.slash" : "bell")
268 .font(.subheadline.weight(.medium))
269 .frame(maxWidth: .infinity)
270 .padding(.vertical, 10)
271 .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
272 }
273 .buttonStyle(.plain)
274 .padding(.top, 4)
275 }
194 276
195 private var followButton: some View { 277 private var followButton: some View {
196 Button { 278 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
@@ -13,6 +13,7 @@ struct MoonriseApp: App {
13 @AppStorage(SettingsKey.onboarded) private var onboarded = false 13 @AppStorage(SettingsKey.onboarded) private var onboarded = false
14 @AppStorage(SettingsKey.nightActive) private var nightActive = false 14 @AppStorage(SettingsKey.nightActive) private var nightActive = false
15 @State private var router = Router.shared 15 @State private var router = Router.shared
16 @Environment(\.scenePhase) private var scenePhase
16 17
17 var body: some Scene { 18 var body: some Scene {
18 WindowGroup { 19 WindowGroup {
@@ -34,6 +35,7 @@ struct MoonriseApp: App {
34 .sheet(item: $router.libraryPage) { page in 35 .sheet(item: $router.libraryPage) { page in
35 NavigationStack { LibraryPageView(page: page) } 36 NavigationStack { LibraryPageView(page: page) }
36 } 37 }
38 .onChange(of: scenePhase) { if scenePhase == .active { Task { await LaunchPassNotifications.refresh() } } }
37 } 39 }
38 } 40 }
39} 41}
Packages/MoonriseKit/Package.swift +1 −1
@@ -25,7 +25,7 @@ let package = Package(
25 dependencies: ["SkyEngine"], 25 dependencies: ["SkyEngine"],
26 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json"), .copy("Resources/messier.json")] 26 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json"), .copy("Resources/messier.json")]
27 ), 27 ),
28 .target(name: "Feeds"), 28 .target(name: "Feeds", dependencies: ["SkyEngine"]),
29 .target(name: "ObservingLog"), 29 .target(name: "ObservingLog"),
30 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]), 30 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
31 .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]), 31 .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]),
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
@@ -130,4 +130,13 @@ extension SkyEngine {
130 } 130 }
131 return out 131 return out
132 } 132 }
133
134 /// Visible passes of any of `elements`, earliest first, each set searched for `hours` from the later of `start`
135 /// and its epoch: a set published before launch is epoched at deployment and means nothing earlier.
136 public static func visiblePasses(of elements: [OrbitalElements], from start: Date, hours: Double = 72, observer: Observer) -> [SatellitePass] {
137 elements.flatMap { e in
138 passes(of: e, from: max(start, e.epochDate ?? start), hours: hours, observer: observer).filter(\.visible)
139 }
140 .sorted { $0.rise < $1.rise }
141 }
133} 142}
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});