Commit 365129e599
Verified · cmc
Layout: unified · split
App/Sources/LaunchPassNotifications.swift added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | import Feeds | |
| 2 | import SkyEngine | |
| 3 | import SwiftUI | |
| 4 | import 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 | |
| 9 | enum 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 @@ | ||
| 1 | 1 | import ActivityKit |
| 2 | 2 | import Feeds |
| 3 | import SkyEngine | |
| 3 | 4 | import SwiftUI |
| 4 | 5 | |
| 5 | 6 | /// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. |
| @@ -64,6 +65,13 @@ struct LaunchesScreen: View { | ||
| 64 | 65 | Button { selected = launch } label: { row(launch, index: i + 1) } |
| 65 | 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 | 75 | Text("Launch data: The Space Devs, Launch Library 2.") |
| 68 | 76 | .font(.caption2).dimmed(0.5).padding(.vertical, 16) |
| 69 | 77 | } else if error == nil { |
| @@ -77,7 +85,10 @@ struct LaunchesScreen: View { | ||
| 77 | 85 | if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return } |
| 78 | 86 | do { |
| 79 | 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 | 92 | error = nil |
| 82 | 93 | } catch { |
| 83 | 94 | self.error = "Couldn't load launches. Pull to retry." |
| @@ -168,6 +179,7 @@ struct LaunchDetail: View { | ||
| 168 | 179 | fact("Location", launch.location) |
| 169 | 180 | } |
| 170 | 181 | .font(.subheadline) |
| 182 | visibility.padding(.top, 8) | |
| 171 | 183 | followButton.padding(.top, 8) |
| 172 | 184 | if let message { Text(message).font(.caption).dimmed(0.7) } |
| 173 | 185 | if let d = launch.missionDescription { |
| @@ -184,6 +196,76 @@ struct LaunchDetail: View { | ||
| 184 | 196 | |
| 185 | 197 | @State private var message: String? |
| 186 | 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 | 270 | private var followButton: some View { |
| 189 | 271 | Button { |
App/Sources/LibraryScreen.swift +1 −1
| @@ -10,7 +10,7 @@ private let pageBackground = Color.black | ||
| 10 | 10 | private let tile = Color(white: 0.08) |
| 11 | 11 | |
| 12 | 12 | /// The observer from Settings (a chosen city) or the last known location, else Chicago. |
| 13 | private func libraryObserver() -> Observer { | |
| 13 | func libraryObserver() -> Observer { | |
| 14 | 14 | let d = UserDefaults.standard |
| 15 | 15 | if d.string(forKey: SettingsKey.locationMode) == "city", d.object(forKey: SettingsKey.cityLatitude) != nil { |
| 16 | 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 | 12 | @AppStorage(SettingsKey.onboarded) private var onboarded = false |
| 13 | 13 | @AppStorage(SettingsKey.nightActive) private var nightActive = false |
| 14 | 14 | @State private var router = Router.shared |
| 15 | @Environment(\.scenePhase) private var scenePhase | |
| 15 | 16 | |
| 16 | 17 | var body: some Scene { |
| 17 | 18 | WindowGroup { |
| @@ -30,6 +31,7 @@ struct MoonriseApp: App { | ||
| 30 | 31 | .fullScreenCover(isPresented: .constant(!onboarded)) { |
| 31 | 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 | 24 | dependencies: ["SkyEngine"], |
| 25 | 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 | 28 | .target(name: "ObservingLog"), |
| 29 | 29 | .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]), |
| 30 | 30 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
Packages/MoonriseKit/Sources/Feeds/Launches.swift +7
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import Foundation |
| 2 | import SkyEngine | |
| 2 | 3 | |
| 3 | 4 | /// An upcoming launch as served by the Moonrise backend (from Launch Library 2). |
| 4 | 5 | public struct Launch: Codable, Sendable, Identifiable, Hashable { |
| @@ -20,11 +21,17 @@ public struct Launch: Codable, Sendable, Identifiable, Hashable { | ||
| 20 | 21 | public var location: String |
| 21 | 22 | public var padLatitude: Double? |
| 22 | 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 | |
| 25 | 30 | public struct LaunchList: Codable, Sendable { |
| 26 | 31 | public var fetchedAt: Date? |
| 27 | 32 | public var launches: [Launch] |
| 33 | /// Launched in the last 14 days, latest first. | |
| 34 | public var recent: [Launch]? | |
| 28 | 35 | } |
| 29 | 36 | |
| 30 | 37 | public enum LaunchFeed { |
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +9
| @@ -118,4 +118,13 @@ extension SkyEngine { | ||
| 118 | 118 | } |
| 119 | 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 | 18 | #expect(list.launches[0].net == (try Date("2026-10-19T10:30:00Z", strategy: .iso8601))) |
| 19 | 19 | #expect(list.launches[1].mission == nil) |
| 20 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | // CelesTrak GP for the ISS, 2026-10-18. | |
| 6 | private 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. | |
| 2 | ALTER TABLE launches ADD COLUMN launch_designator TEXT; | |
server/src/celestrak.ts +136 −22
| @@ -22,42 +22,156 @@ export interface OMM { | ||
| 22 | 22 | |
| 23 | 23 | const 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 | |
| 25 | export function pick(rows: Record<string, unknown>[]): OMM[] { | |
| 25 | export type Row = Record<string, unknown>; | |
| 26 | ||
| 27 | export function pick(rows: Row[]): OMM[] { | |
| 26 | 28 | return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM); |
| 27 | 29 | } |
| 28 | 30 | |
| 29 | /** | |
| 30 | * One element set per recent Starlink launch still low enough to be seen as a train, | |
| 31 | * the satellite with the median mean motion, named "Starlink train (2026-232)". | |
| 32 | */ | |
| 33 | export function starlinkTrains(rows: Record<string, unknown>[]): OMM[] { | |
| 34 | const groups = new Map<string, Record<string, unknown>[]>(); | |
| 31 | /** Objects grouped by launch, keyed by international designator ("2026-232"). */ | |
| 32 | export function byDesignator(rows: Row[]): Map<string, Row[]> { | |
| 33 | const groups = new Map<string, Row[]>(); | |
| 35 | 34 | for (const r of rows) { |
| 36 | if (!String(r.OBJECT_NAME).startsWith("STARLINK")) continue; | |
| 37 | 35 | const launch = String(r.OBJECT_ID).slice(0, 8); |
| 38 | 36 | groups.set(launch, [...(groups.get(launch) ?? []), r]); |
| 39 | 37 | } |
| 38 | return groups; | |
| 39 | } | |
| 40 | ||
| 41 | const isStarlink = (r: Row) => String(r.OBJECT_NAME).startsWith("STARLINK"); | |
| 42 | ||
| 43 | /** The Starlink satellite with the median mean motion, named "Starlink train (<label>)". */ | |
| 44 | function 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. */ | |
| 51 | export function starlinkTrains(groups: Map<string, Row[]>): OMM[] { | |
| 40 | 52 | const out: OMM[] = []; |
| 41 | for (const [launch, sats] of groups) { | |
| 42 | const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION)); | |
| 43 | const median = sorted[Math.floor(sorted.length / 2)]; | |
| 44 | if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue; | |
| 45 | out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` }); | |
| 53 | for (const [label, rows] of groups) { | |
| 54 | const t = train(rows, label); | |
| 55 | if (t && t.MEAN_MOTION > TRAIN_MEAN_MOTION) out.push(t); | |
| 46 | 56 | } |
| 47 | 57 | return out; |
| 48 | 58 | } |
| 49 | 59 | |
| 50 | async function group(name: string): Promise<Record<string, unknown>[]> { | |
| 51 | const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, { | |
| 52 | headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" }, | |
| 53 | }); | |
| 60 | // SupGP: pre-launch and post-deployment Starlink sets from SpaceX data. OBJECT_ID and | |
| 61 | // NORAD_CAT_ID are placeholders (a pre-launch OBJECT_ID can be a designator already given to | |
| 62 | // another launch), so SupGP sets are tied to launches by file name and epoch only. | |
| 63 | ||
| 64 | /** "Starlink Group 15-15" → "starlink-g15-15"; null for other missions. */ | |
| 65 | export 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. */ | |
| 71 | export 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 | ||
| 81 | function 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 | ||
| 88 | const 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 | */ | |
| 94 | export 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 | ||
| 99 | export 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). */ | |
| 108 | export 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 | ||
| 116 | const newest = (rows: Row[]) => Math.max(...rows.map(epoch)); | |
| 117 | ||
| 118 | /** A Starlink train, then rocket bodies and other objects (no debris), at most 8. */ | |
| 119 | function 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 | ||
| 127 | export 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 | */ | |
| 139 | export 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 | ||
| 157 | const HEADERS = { "user-agent": "moonrise-server (sh.krz.moonrise)" }; | |
| 158 | ||
| 159 | async 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 | 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. */ | |
| 59 | export async function fetchElements(): Promise<OMM[]> { | |
| 60 | const visual = pick(await group("visual")); | |
| 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. */ | |
| 166 | export async function fetchGroups(): Promise<{ visual: Row[]; recent: Row[] }> { | |
| 167 | const visual = await group("visual"); | |
| 61 | 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. */ | |
| 173 | export 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 @@ | ||
| 1 | 1 | import { send, type ApnsConfig } from "./apns"; |
| 2 | 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; |
| 3 | import { fetchElements } from "./celestrak"; | |
| 3 | import { compose, fetchGroups, fetchSupGP, fitsLaunch, supgpFile, supgpLaunches, type LaunchElements, type LaunchRef, type Row } from "./celestrak"; | |
| 4 | 4 | import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live"; |
| 5 | import { fetchUpcoming, type Launch } from "./ll2"; | |
| 5 | import { fetchLaunches, PREVIOUS_URL, UPCOMING_URL, type Launch } from "./ll2"; | |
| 6 | 6 | import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news"; |
| 7 | 7 | import { PRIVACY_HTML } from "./privacy"; |
| 8 | 8 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; |
| @@ -28,6 +28,8 @@ interface DeviceRequest { | ||
| 28 | 28 | |
| 29 | 29 | const LAUNCH_CRON = "*/10 * * * *"; |
| 30 | 30 | const SATELLITE_CRON = "7 */2 * * *"; |
| 31 | /** Launches are kept this long after NET, so a launch page and its first pass outlive liftoff. */ | |
| 32 | const RETENTION_MS = 14 * 86400_000; | |
| 31 | 33 | |
| 32 | 34 | const json = (body: unknown, status = 200, maxAge?: number) => |
| 33 | 35 | Response.json(body, { status, headers: maxAge && status === 200 ? { "cache-control": `public, max-age=${maxAge}` } : {} }); |
| @@ -97,7 +99,7 @@ export default { | ||
| 97 | 99 | |
| 98 | 100 | async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { |
| 99 | 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 | 103 | if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)])); |
| 102 | 104 | else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now)); |
| 103 | 105 | else ctx.waitUntil(pollSpaceWeather(env, now)); |
| @@ -154,15 +156,22 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> { | ||
| 154 | 156 | async function launches(env: Env, now: Date): Promise<Response> { |
| 155 | 157 | const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); |
| 156 | 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 | 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 | 171 | return json({ |
| 159 | 172 | fetchedAt: fetched?.fetched_at ?? null, |
| 160 | launches: rows.results.map((r) => ({ | |
| 161 | id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end, | |
| 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 | })), | |
| 173 | launches: rows.results.map(shape), | |
| 174 | recent: recent.results.map(shape), | |
| 166 | 175 | }, 200, MAX_AGE.launches); |
| 167 | 176 | } |
| 168 | 177 | |
| @@ -170,34 +179,72 @@ async function launches(env: Env, now: Date): Promise<Response> { | ||
| 170 | 179 | async function pollLaunches(env: Env, now: Date): Promise<void> { |
| 171 | 180 | let list: Launch[]; |
| 172 | 181 | try { |
| 173 | list = await fetchUpcoming(env.LL2_API_KEY); | |
| 182 | list = await fetchLaunches(UPCOMING_URL, env.LL2_API_KEY); | |
| 174 | 183 | } catch (e) { |
| 175 | 184 | console.error("ll2", String(e)); |
| 176 | 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 | 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 | 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) | |
| 181 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) | |
| 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) | |
| 198 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) | |
| 182 | 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 | 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)), | |
| 187 | env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - 7 * 86400_000).toISOString()), | |
| 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)), | |
| 205 | env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - RETENTION_MS).toISOString()), | |
| 188 | 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 | |
| 192 | 210 | async function pollSatellites(env: Env, now: Date): Promise<void> { |
| 211 | let groups: { visual: Row[]; recent: Row[] }; | |
| 193 | 212 | try { |
| 194 | const elements = await fetchElements(); | |
| 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(); | |
| 213 | groups = await fetchGroups(); | |
| 197 | 214 | } catch (e) { |
| 198 | 215 | console.error("celestrak", String(e)); |
| 199 | 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 | */ | |
| 235 | async 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 | |
| 203 | 250 | async function pollSpaceWeather(env: Env, now: Date): Promise<void> { |
server/src/ll2.ts +8 −2
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | // Images are not used: their licences are often "Unknown". |
| 3 | 3 | |
| 4 | 4 | export 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. */ | |
| 6 | export const PREVIOUS_URL = "https://ll.thespacedevs.com/2.3.0/launches/previous/?limit=10&mode=normal"; | |
| 5 | 7 | |
| 6 | 8 | export interface Launch { |
| 7 | 9 | id: string; |
| @@ -22,6 +24,8 @@ export interface Launch { | ||
| 22 | 24 | padLatitude: number | null; |
| 23 | 25 | padLongitude: number | null; |
| 24 | 26 | lastUpdated: string; |
| 27 | /** International designator, e.g. "2026-232"; null before launch. */ | |
| 28 | designator: string | null; | |
| 25 | 29 | } |
| 26 | 30 | |
| 27 | 31 | interface Raw { |
| @@ -31,6 +35,7 @@ interface Raw { | ||
| 31 | 35 | window_start?: string | null; |
| 32 | 36 | window_end?: string | null; |
| 33 | 37 | last_updated: string; |
| 38 | launch_designator?: string | null; | |
| 34 | 39 | status: { abbrev: string; name: string }; |
| 35 | 40 | launch_service_provider: { name: string }; |
| 36 | 41 | rocket: { configuration: { full_name?: string; name: string } }; |
| @@ -60,14 +65,15 @@ export function normalize(r: Raw): Launch { | ||
| 60 | 65 | padLatitude: num(r.pad.latitude), |
| 61 | 66 | padLongitude: num(r.pad.longitude), |
| 62 | 67 | lastUpdated: r.last_updated, |
| 68 | designator: r.launch_designator ?? null, | |
| 63 | 69 | }; |
| 64 | 70 | } |
| 65 | 71 | |
| 66 | 72 | /** `apiKey` lifts the anonymous per-IP limit, which Cloudflare's shared egress IPs often exhaust (HTTP 429). */ |
| 67 | export async function fetchUpcoming(apiKey?: string): Promise<Launch[]> { | |
| 73 | export async function fetchLaunches(url: string, apiKey?: string): Promise<Launch[]> { | |
| 68 | 74 | const headers: Record<string, string> = { "user-agent": "moonrise-server (sh.krz.moonrise)" }; |
| 69 | 75 | if (apiKey) headers.authorization = `Token ${apiKey}`; |
| 70 | const res = await fetch(UPCOMING_URL, { headers }); | |
| 76 | const res = await fetch(url, { headers }); | |
| 71 | 77 | if (!res.ok) throw new Error(`LL2 ${res.status}`); |
| 72 | 78 | const body = (await res.json()) as { results: Raw[] }; |
| 73 | 79 | return body.results.map(normalize); |
server/test/celestrak.test.ts +109 −3
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import assert from "node:assert/strict"; |
| 2 | 2 | import { test } from "node:test"; |
| 3 | import { pick, starlinkTrains } from "../src/celestrak.ts"; | |
| 3 | import { byDesignator, compose, fitsLaunch, parseSupGP, pick, starlinkTrains, supgpFile, supgpLaunches } from "../src/celestrak.ts"; | |
| 4 | 4 | |
| 5 | 5 | test("keeps only the SGP4 fields", () => { |
| 6 | 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 | 20 | OBJECT_NAME: `STARLINK-${n}`, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: "2026-10-18T00:00:00", MEAN_MOTION: mm, |
| 21 | 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 | 24 | sat("2026-232A", 1, 15.9), sat("2026-232B", 2, 16.0), sat("2026-232C", 3, 15.95), |
| 25 | 25 | sat("2026-180A", 4, 15.06), sat("2026-180B", 5, 15.07), |
| 26 | 26 | { ...sat("2026-232D", 6, 16.1), OBJECT_NAME: "FALCON 9 R/B" }, |
| 27 | ]); | |
| 27 | ])); | |
| 28 | 28 | assert.equal(trains.length, 1); |
| 29 | 29 | assert.equal(trains[0].OBJECT_NAME, "Starlink train (2026-232)"); |
| 30 | 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. | |
| 35 | const 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 | ||
| 38 | test("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 | ||
| 45 | test("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 | ||
| 57 | test("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 | ||
| 70 | const launch = (id: string, mission: string | null, net: string, designator: string | null = null, provider = "SpaceX") => ({ id, mission, provider, net, designator }); | |
| 71 | ||
| 72 | test("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 | ||
| 88 | const 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 | ||
| 93 | test("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 | ||
| 116 | test("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 | ||
| 127 | test("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 @@ | ||
| 1 | 1 | import assert from "node:assert/strict"; |
| 2 | 2 | import { test } from "node:test"; |
| 3 | import { normalize } from "../src/ll2.ts"; | |
| 3 | import { normalize, PREVIOUS_URL } from "../src/ll2.ts"; | |
| 4 | 4 | |
| 5 | 5 | // Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17. |
| 6 | 6 | const raw = { |
| @@ -31,3 +31,28 @@ test("tolerates a missing mission", () => { | ||
| 31 | 31 | assert.equal(l.mission, null); |
| 32 | 32 | assert.equal(l.orbit, null); |
| 33 | 33 | }); |
| 34 | ||
| 35 | // Trimmed from /2.3.0/launches/previous/?mode=normal on 2026-10-19. | |
| 36 | const 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 | ||
| 51 | test("keeps the international designator once launched", () => { | |
| 52 | assert.equal(normalize(launched).designator, "2026-232"); | |
| 53 | assert.equal(normalize(raw).designator, null); | |
| 54 | }); | |
| 55 | ||
| 56 | test("previous launches are fetched ten at a time", () => { | |
| 57 | assert.match(PREVIOUS_URL, /\/launches\/previous\/\?limit=10&mode=normal$/); | |
| 58 | }); | |