Commit 05f6b6597a
Unsigned
Layout: unified · split
App/Shared/EventActivity.swift added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import ActivityKit | |
| 2 | import Foundation | |
| 3 | ||
| 4 | /// Live Activity for a mission covered live. Shared by the app and the widget extension. | |
| 5 | struct EventActivityAttributes: ActivityAttributes { | |
| 6 | struct ContentState: Codable, Hashable { | |
| 7 | var phase: String | |
| 8 | var next: String? | |
| 9 | var nextAt: Date? | |
| 10 | var distanceKm: Double | |
| 11 | } | |
| 12 | ||
| 13 | var eventID: String | |
| 14 | var title: String | |
| 15 | var launch: Date | |
| 16 | } | |
App/Sources/LiveSection.swift added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | import ActivityKit | |
| 2 | import Feeds | |
| 3 | import SkyEngine | |
| 4 | import SwiftUI | |
| 5 | ||
| 6 | /// A mission being covered live: where it is between Earth and the Moon, its phase, and a Lock Screen follow. | |
| 7 | /// Shown only while the backend reports an event. In debug builds, the user default "liveReplayAt" | |
| 8 | /// (ISO 8601) replays a past event at that moment. | |
| 9 | struct LiveSection: View { | |
| 10 | var ink: Color | |
| 11 | var accent: Color | |
| 12 | @State private var coverage: LiveCoverage? | |
| 13 | @State private var following = false | |
| 14 | ||
| 15 | private var replayAt: Date? { | |
| 16 | #if DEBUG | |
| 17 | UserDefaults.standard.string(forKey: "liveReplayAt").flatMap { try? Date($0, strategy: .iso8601) } | |
| 18 | #else | |
| 19 | nil | |
| 20 | #endif | |
| 21 | } | |
| 22 | ||
| 23 | var body: some View { | |
| 24 | // A VStack rather than a Group: an empty Group has no view for .task to attach to. | |
| 25 | VStack(alignment: .leading, spacing: 0) { | |
| 26 | if let c = coverage { | |
| 27 | VStack(alignment: .leading, spacing: 10) { | |
| 28 | HStack { | |
| 29 | Text(replayAt == nil ? "LIVE" : "REPLAY").font(.caption2.weight(.bold)).tracking(0.8) | |
| 30 | .padding(.horizontal, 6).padding(.vertical, 2) | |
| 31 | .background(accent, in: RoundedRectangle(cornerRadius: 3)) | |
| 32 | Text(c.event.title).font(.title3.weight(.medium)) | |
| 33 | } | |
| 34 | EarthMoonMap(coverage: c, ink: ink, accent: accent) | |
| 35 | .aspectRatio(1, contentMode: .fit) | |
| 36 | .accessibilityLabel("Map of \(c.event.title) between Earth and the Moon") | |
| 37 | facts(c) | |
| 38 | if replayAt == nil { | |
| 39 | Button(following ? "Following on the Lock Screen" : "Follow on the Lock Screen") { follow(c) } | |
| 40 | .disabled(following) | |
| 41 | .font(.subheadline.weight(.medium)) | |
| 42 | } | |
| 43 | Text("Trajectory: NASA/JPL Horizons, every 30 minutes.").font(.caption2).dimmed(0.5) | |
| 44 | } | |
| 45 | .padding(.bottom, 24) | |
| 46 | } | |
| 47 | } | |
| 48 | .task { | |
| 49 | while !Task.isCancelled { | |
| 50 | if let apiBase { | |
| 51 | do { | |
| 52 | let c = try await LiveCoverage.fetch(base: apiBase, at: replayAt) | |
| 53 | if let c { | |
| 54 | coverage = c | |
| 55 | await update(c) | |
| 56 | } | |
| 57 | } catch { | |
| 58 | log.error("live: \(error.localizedDescription, privacy: .public)") | |
| 59 | } | |
| 60 | } | |
| 61 | try? await Task.sleep(for: .seconds(600)) | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | private func facts(_ c: LiveCoverage) -> some View { | |
| 67 | let moon = SkyEngine.geocentricMoonEcliptic(at: c.at) | |
| 68 | let fromMoon = c.points.last.map { p in ((p.position - moon) * (p.position - moon)).sum().squareRoot() } | |
| 69 | let elapsed = c.at.timeIntervalSince(c.event.launch) | |
| 70 | return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | |
| 71 | GridRow { Text("Mission time").dimmed(0.7); Text("\(Int(elapsed) / 86_400) d \(Int(elapsed) % 86_400 / 3600) h \(Int(elapsed) % 3600 / 60) m") } | |
| 72 | if let p = c.phase.current { GridRow { Text("Phase").dimmed(0.7); Text(p.label) } } | |
| 73 | if let n = c.phase.next { GridRow { Text("Next").dimmed(0.7); Text("\(n.label), \(n.at.formatted(date: .abbreviated, time: .shortened))") } } | |
| 74 | if let d = c.distanceKm { GridRow { Text("From Earth's centre").dimmed(0.7); Text("\(Int(d).formatted()) km") } } | |
| 75 | if let d = fromMoon { GridRow { Text("From the Moon's centre").dimmed(0.7); Text("\(Int(d).formatted()) km") } } | |
| 76 | } | |
| 77 | .font(.subheadline.monospacedDigit()) | |
| 78 | } | |
| 79 | ||
| 80 | private func state(_ c: LiveCoverage) -> EventActivityAttributes.ContentState { | |
| 81 | EventActivityAttributes.ContentState(phase: c.phase.current?.label ?? "", next: c.phase.next?.label, nextAt: c.phase.next?.at, distanceKm: c.distanceKm ?? 0) | |
| 82 | } | |
| 83 | ||
| 84 | private func follow(_ c: LiveCoverage) { | |
| 85 | let attributes = EventActivityAttributes(eventID: c.event.id, title: c.event.title, launch: c.event.launch) | |
| 86 | _ = try? Activity.request(attributes: attributes, content: .init(state: state(c), staleDate: .now.addingTimeInterval(1800))) | |
| 87 | following = true | |
| 88 | } | |
| 89 | ||
| 90 | /// Updates a followed activity while the app runs; ends it after the event. | |
| 91 | private func update(_ c: LiveCoverage) async { | |
| 92 | for a in Activity<EventActivityAttributes>.activities where a.attributes.eventID == c.event.id { | |
| 93 | following = true | |
| 94 | if c.at > c.event.end { | |
| 95 | await a.end(nil, dismissalPolicy: .default) | |
| 96 | } else { | |
| 97 | await a.update(.init(state: state(c), staleDate: .now.addingTimeInterval(1800))) | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | /// Earth at the centre, the Moon's orbit and position, and the trajectory so far, on the ecliptic plane. | |
| 104 | struct EarthMoonMap: View { | |
| 105 | var coverage: LiveCoverage | |
| 106 | var ink: Color | |
| 107 | var accent: Color | |
| 108 | ||
| 109 | var body: some View { | |
| 110 | Canvas { context, size in | |
| 111 | let moon = SkyEngine.geocentricMoonEcliptic(at: coverage.at) | |
| 112 | let moonDistance = (moon * moon).sum().squareRoot() | |
| 113 | let farthest = coverage.points.map { ($0.position * $0.position).sum().squareRoot() }.max() ?? 0 | |
| 114 | let scale = min(size.width, size.height) / 2 / (max(farthest, moonDistance) * 1.08) | |
| 115 | let c = CGPoint(x: size.width / 2, y: size.height / 2) | |
| 116 | func pt(_ v: SIMD3<Double>) -> CGPoint { CGPoint(x: c.x + v.x * scale, y: c.y - v.y * scale) } | |
| 117 | ||
| 118 | let r = moonDistance * scale | |
| 119 | context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.2)), style: StrokeStyle(lineWidth: 0.5, dash: [3, 4])) | |
| 120 | context.fill(Path(ellipseIn: CGRect(x: c.x - 5, y: c.y - 5, width: 10, height: 10)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95))) | |
| 121 | context.draw(Text("Earth").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: c.y + 14)) | |
| 122 | ||
| 123 | let m = pt(moon) | |
| 124 | context.fill(Path(ellipseIn: CGRect(x: m.x - 4, y: m.y - 4, width: 8, height: 8)), with: .color(ink)) | |
| 125 | context.draw(Text("Moon").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: m.y + 13)) | |
| 126 | ||
| 127 | if let first = coverage.points.first, let last = coverage.points.last { | |
| 128 | var path = Path() | |
| 129 | path.move(to: pt(first.position)) | |
| 130 | for p in coverage.points.dropFirst() { path.addLine(to: pt(p.position)) } | |
| 131 | context.stroke(path, with: .color(accent.opacity(0.8)), lineWidth: 1.2) | |
| 132 | let q = pt(last.position) | |
| 133 | context.fill(Path(ellipseIn: CGRect(x: q.x - 4, y: q.y - 4, width: 8, height: 8)), with: .color(accent)) | |
| 134 | context.draw(Text(coverage.event.title).font(.caption2).foregroundStyle(accent), at: CGPoint(x: q.x + 7, y: q.y), anchor: .leading) | |
| 135 | } | |
| 136 | } | |
| 137 | } | |
| 138 | } | |
App/Sources/SystemScreen.swift +2
| @@ -26,6 +26,8 @@ struct SystemScreen: View { | ||
| 26 | 26 | Text("Solar system").font(.title.weight(.medium)) |
| 27 | 27 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16) |
| 28 | 28 | |
| 29 | LiveSection(ink: ink, accent: accent) | |
| 30 | ||
| 29 | 31 | // Labels in this dense diagram stop growing at xxLarge; the table below scales fully. |
| 30 | 32 | OrreryView(date: date, ink: ink, accent: accent) |
| 31 | 33 | .dynamicTypeSize(...DynamicTypeSize.xxLarge) |
App/Widgets/EventLiveActivity.swift added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import ActivityKit | |
| 2 | import SwiftUI | |
| 3 | import WidgetKit | |
| 4 | ||
| 5 | struct EventLiveActivity: Widget { | |
| 6 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | |
| 7 | ||
| 8 | var body: some WidgetConfiguration { | |
| 9 | ActivityConfiguration(for: EventActivityAttributes.self) { context in | |
| 10 | VStack(alignment: .leading, spacing: 4) { | |
| 11 | HStack { | |
| 12 | Text(context.attributes.title).font(.system(size: 17, weight: .medium)) | |
| 13 | Spacer() | |
| 14 | Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit()) | |
| 15 | .multilineTextAlignment(.trailing) | |
| 16 | } | |
| 17 | Text(context.state.phase).font(.system(size: 14)) | |
| 18 | HStack { | |
| 19 | Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8) | |
| 20 | Spacer() | |
| 21 | if let next = context.state.next, let at = context.state.nextAt { | |
| 22 | Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | .padding(16) | |
| 27 | .foregroundStyle(ink) | |
| 28 | .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141)) | |
| 29 | } dynamicIsland: { context in | |
| 30 | DynamicIsland { | |
| 31 | DynamicIslandExpandedRegion(.leading) { | |
| 32 | Text(context.attributes.title).font(.system(size: 14, weight: .medium)) | |
| 33 | } | |
| 34 | DynamicIslandExpandedRegion(.trailing) { | |
| 35 | Text(context.attributes.launch, style: .timer).font(.system(size: 14).monospacedDigit()) | |
| 36 | } | |
| 37 | DynamicIslandExpandedRegion(.bottom) { | |
| 38 | Text("\(context.state.phase) · \(Int(context.state.distanceKm).formatted()) km").font(.system(size: 12)).opacity(0.8) | |
| 39 | } | |
| 40 | } compactLeading: { | |
| 41 | Image(systemName: "moon.stars") | |
| 42 | } compactTrailing: { | |
| 43 | Text(context.attributes.launch, style: .timer).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) | |
| 44 | } minimal: { | |
| 45 | Image(systemName: "moon.stars") | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
App/Widgets/MoonriseWidgets.swift +1
| @@ -11,6 +11,7 @@ struct MoonriseWidgets: WidgetBundle { | ||
| 11 | 11 | PlanetsWidget() |
| 12 | 12 | #if os(iOS) |
| 13 | 13 | LaunchLiveActivity() |
| 14 | EventLiveActivity() | |
| 14 | 15 | #endif |
| 15 | 16 | } |
| 16 | 17 | } |
PLAN.org +5 −1
| @@ -422,7 +422,11 @@ Sky, System, Sun, Launches, Library core (above). | ||
| 422 | 422 | months, 5 to 8 stops each, in Library/Resources/tours. Mission stories |
| 423 | 423 | done for Voyager 1 and 2, Pioneer 10 and 11 and New Horizons, on the orrery. |
| 424 | 424 | - Live coverage of major events (e.g. an Artemis landing) on the orrery |
| 425 | with a Live Activity. | |
| 425 | with a Live Activity. Done: GET /v1/live with a Horizons trajectory, an | |
| 426 | Earth–Moon map on the System tab and a Live Activity. Artemis I is configured | |
| 427 | as a past event for replay; add Artemis II and later missions to | |
| 428 | server/src/live.ts (Horizons ID, window, phases) once their Horizons IDs are | |
| 429 | published. | |
| 426 | 430 | - Maybe: smart-telescope pointing (Seestar and similar). |
| 427 | 431 | |
| 428 | 432 | * risks |
Packages/MoonriseKit/Sources/Feeds/LiveEvent.swift added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A mission covered live by the backend (GET /v1/live): its phases and geocentric trajectory. | |
| 4 | public struct LiveCoverage: Sendable { | |
| 5 | public struct Event: Sendable, Decodable, Hashable { | |
| 6 | public struct Phase: Sendable, Decodable, Hashable { | |
| 7 | public var at: Date | |
| 8 | public var label: String | |
| 9 | } | |
| 10 | ||
| 11 | public var id: String | |
| 12 | public var title: String | |
| 13 | public var launch: Date | |
| 14 | public var end: Date | |
| 15 | public var phases: [Phase] | |
| 16 | } | |
| 17 | ||
| 18 | public struct Point: Sendable, Hashable { | |
| 19 | public var date: Date | |
| 20 | /// km, J2000 ecliptic axes, centred on Earth. | |
| 21 | public var position: SIMD3<Double> | |
| 22 | } | |
| 23 | ||
| 24 | public var event: Event | |
| 25 | public var at: Date | |
| 26 | public var points: [Point] | |
| 27 | ||
| 28 | /// The last phase that has started by `at`, and the next one. | |
| 29 | public var phase: (current: Event.Phase?, next: Event.Phase?) { | |
| 30 | (event.phases.last { $0.at <= at }, event.phases.first { $0.at > at }) | |
| 31 | } | |
| 32 | ||
| 33 | /// Distance from Earth's centre at the latest point, km. | |
| 34 | public var distanceKm: Double? { points.last.map { ($0.position * $0.position).sum().squareRoot() } } | |
| 35 | ||
| 36 | private struct Response: Decodable { | |
| 37 | var event: Event? | |
| 38 | var at: Date? | |
| 39 | var points: [[Double]]? | |
| 40 | } | |
| 41 | ||
| 42 | /// nil when no event is running. | |
| 43 | public static func decode(_ data: Data) throws -> LiveCoverage? { | |
| 44 | let d = JSONDecoder() | |
| 45 | d.dateDecodingStrategy = .custom { decoder in | |
| 46 | let s = try decoder.singleValueContainer().decode(String.self) | |
| 47 | if let date = try? Date(s, strategy: .iso8601) { return date } | |
| 48 | return try Date(s, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) | |
| 49 | } | |
| 50 | let r = try d.decode(Response.self, from: data) | |
| 51 | guard let event = r.event, let at = r.at else { return nil } | |
| 52 | let points = (r.points ?? []).filter { $0.count == 4 }.map { | |
| 53 | Point(date: Date(timeIntervalSince1970: $0[0]), position: SIMD3($0[1], $0[2], $0[3])) | |
| 54 | } | |
| 55 | return LiveCoverage(event: event, at: at, points: points) | |
| 56 | } | |
| 57 | ||
| 58 | /// `at` replays a past event (testing); nil means now. | |
| 59 | public static func fetch(base: URL, at: Date? = nil, session: URLSession = .shared) async throws -> LiveCoverage? { | |
| 60 | var url = base.appending(path: "v1/live") | |
| 61 | if let at { url.append(queryItems: [URLQueryItem(name: "at", value: at.ISO8601Format())]) } | |
| 62 | return try decode(await session.data(from: url).0) | |
| 63 | } | |
| 64 | } | |
Packages/MoonriseKit/Sources/SkyEngine/SkyEngine.swift +8
| @@ -156,6 +156,14 @@ public enum SkyEngine { | ||
| 156 | 156 | return ecliptic(SIMD3(v.x, v.y, v.z)) |
| 157 | 157 | } |
| 158 | 158 | |
| 159 | /// The Moon's geocentric position in km on J2000 ecliptic axes (as JPL Horizons gives with REF_PLANE ECLIPTIC). | |
| 160 | public static func geocentricMoonEcliptic(at date: Date) -> SIMD3<Double> { | |
| 161 | lock.lock() | |
| 162 | defer { lock.unlock() } | |
| 163 | let v = Astronomy_GeoMoon(time(date)) | |
| 164 | return ecliptic(SIMD3(v.x, v.y, v.z)) * 1.495978707e8 | |
| 165 | } | |
| 166 | ||
| 159 | 167 | /// J2000 equatorial to J2000 ecliptic axes. |
| 160 | 168 | static func ecliptic(_ v: SIMD3<Double>) -> SIMD3<Double> { |
| 161 | 169 | let e = 23.4392911 * Double.pi / 180 |
Packages/MoonriseKit/Tests/FeedsTests/Fixtures/live-artemis1.json added +1
| @@ -0,0 +1 @@ | ||
| 1 | {"event":{"id":"artemis-1","title":"Artemis I","horizonsId":"-1023","launch":"2022-11-16T06:47:44Z","end":"2022-12-11T17:40:00Z","phases":[{"at":"2022-11-16T06:47:44Z","label":"Launch"},{"at":"2022-11-21T12:44:00Z","label":"Outbound lunar flyby"},{"at":"2022-11-25T21:52:00Z","label":"Distant retrograde orbit"},{"at":"2022-12-01T21:53:00Z","label":"Leaves lunar orbit"},{"at":"2022-12-05T16:43:00Z","label":"Return lunar flyby"},{"at":"2022-12-11T17:40:00Z","label":"Splashdown"}]},"at":"2022-11-21T13:00:00.000Z","points":[[1668588420,-3171.295854723327,10537.75290196367,1526.073161998125],[1668590220,-15521.4796823214,12804.24607442801,2946.184008095224],[1668592020,-25432.20698926562,12677.87625316669,3858.321483243862],[1668593820,-33908.3825863305,11792.39376146054,4547.607445455554],[1668595620,-41443.30898636936,10568.43193950377,5109.333137268746],[1668597420,-48300.44039077931,9167.543004791032,5587.009266456816],[1668599220,-54638.33132337555,7666.250451375013,6004.401403551552],[1668601020,-60560.94175827918,6105.729715837361,6376.040270773031],[1668602820,-66140.79668791656,4510.105912265922,6711.52536192494],[1668604620,-71430.90106690428,2894.392837650878,7017.566594105197],[1668606420,-76471.41672952138,1268.356891704598,7299.0600305677],[1668608220,-81293.6616804418,-361.4350190351443,7559.701064306679],[1668610020,-85931.23786954764,-1964.797803202634,7804.340335943878],[1668611820,-90407.26154311905,-3530.44021756236,8035.883526772708],[1668613620,-94727.28159424206,-5090.023044001353,8253.553192136418],[1668615420,-98905.45534834618,-6642.000767095135,8458.858539027206],[1668617220,-102953.7967899121,-8185.264651123171,8653.05602294943],[1668619020,-106882.6105947254,-9719.021551743514,8837.204446655545],[1668620820,-110700.8187225961,-11242.70961895694,9012.205595376923],[1668622620,-114416.2104877036,-12755.9385276034,9178.834779698129],[1668624420,-118035.6369979949,-14258.44638603938,9337.764179305575],[1668626220,-121565.1644022513,-15750.06821651864,9489.580949151306],[1668628020,-125010.7546178693,-17230.69509662861,9634.832460701882],[1668629820,-128376.2182961129,-18700.46033678309,9773.879006211619],[1668631620,-131666.3825729298,-20159.22032256566,9907.192814706541],[1668633420,-134885.1860424861,-21607.01516916345,10035.13748878905],[1668635220,-138036.2047372087,-23043.90993184495,10158.0397280642],[1668637020,-141122.6979872585,-24469.98858294758,10276.19433996948],[1668638820,-144147.6470185675,-25885.34932348324,10389.86841161134],[1668640620,-147113.7876608123,-27290.10091405481,10499.30480862419],[1668642420,-150023.6382248145,-28684.35979384949,10604.72512783623],[1668644220,-152879.5234052394,-30068.24781070211,10706.33220498502],[1668646020,-155683.5948755659,-31441.89042935168,10804.31225577766],[1668647820,-158437.8491279732,-32805.41531535021,10898.83671426376],[1668649620,-161144.1429917543,-34158.95121612768,10990.06381847717],[1668651420,-163804.2071946167,-35502.62707665713,11078.13998491749],[1668653220,-166419.6582599421,-36836.57134385519,11163.20100477543],[1668655020,-168992.008985923,-38160.91142020775,11245.37308963409],[1668656820,-171522.6777059821,-39475.77323781312,11324.7737887484],[1668658620,-174012.9965050332,-40781.28092964723,11401.51279700709],[1668660420,-176464.2185263851,-42077.55657764144,11475.6926686302],[1668662220,-178877.524496443,-43364.72002441518,11547.40945007079],[1668664020,-181254.0285633116,-44642.88873559646,11616.7532426769],[1668665820,-183594.7835374511,-45912.17770231586,11683.80870460418],[1668667620,-185900.7856083887,-47172.69937768635,11748.65549964453],[1668669420,-188172.9785999716,-48424.56363914892,11811.36869978689],[1668671220,-190412.2578182143,-49667.87777260864,11872.01914705618],[1668673020,-192619.4735396779,-50902.74647351751,11930.67377969302],[1668674820,-194795.4341792105,-52129.27186155313,11987.39592670927],[1668676620,-196940.9091753185,-53347.55350671562,12042.24557466047],[1668678420,-199056.6316194663,-54557.68846296577,12095.2796095588],[1668680220,-201143.3006609813,-55759.77130932242,12146.55203690018],[1668682020,-203201.5837080249,-56953.89419594768,12196.11418201544],[1668683820,-205232.1184456912,-58140.14689335613,12244.01487294037],[1668685620,-207235.4059266311,-59318.71449323297,12290.20532296473],[1668687420,-209211.9988727955,-60489.70990169961,12334.70277893374],[1668689220,-211162.5959952878,-61653.09062265786,12377.67191715857],[1668691020,-213087.7331901471,-62808.93740586034,12419.15313614721],[1668692820,-214987.9245604612,-63957.32889878107,12459.18504446876],[1668694620,-216863.6636589132,-65098.34170292957,12497.80457029773],[1668696420,-218715.4246392769,-66232.05042947337,12535.04706251706],[1668698220,-220543.6633245064,-67358.5277537409,12570.94638414446],[1668700020,-222348.8182004464,-68477.84446948313,12605.53499882324],[1668701820,-224131.3113394606,-69590.06954146374,12638.84405095085],[1668703620,-225891.5492617133,-70695.27015740892,12670.90344004585],[1668705420,-227629.9237375961,-71793.51177817462,12701.74188981716],[1668707220,-229346.8125379495,-72884.85818724253,12731.38701242413],[1668709020,-231045.8188853156,-73971.12963986388,12760.80374807614],[1668710820,-232721.059713353,-75049.00797251158,12788.21493229661],[1668712620,-234375.8716441042,-76120.17299981482,12814.50949909696],[1668714420,-236010.5830529653,-77184.6822676053,12839.71117151486],[1668716220,-237625.5114008492,-78242.59186938025,12863.84282284419],[1668718020,-239220.9637408819,-79293.95648667071,12886.92651916837],[1668719820,-240797.2371964694,-80338.82942909948,12908.98355925044],[1668721620,-242354.6194095323,-81377.26267211992,12930.03451190697],[1668723420,-243893.3889637323,-82409.30689410909,12950.09925111739],[1668725220,-245413.8157832747,-83435.01151220068,12969.19698899646],[1668727020,-246916.1615083046,-84454.42471625104,12987.34630679965],[1668728820,-248400.679850237,-85467.59350232813,13004.56518410433],[1668730620,-249867.6169270898,-86474.5637046911,13020.87102630033],[1668732420,-251317.2115802458,-87475.38002651338,13036.28069049129],[1668734220,-252749.6956747627,-88470.08606984763,13050.81050994489],[1668736020,-254165.2943830662,-89458.72436413597,13064.47631716138],[1668737820,-255564.2264543584,-90441.33639405695,13077.29346568354],[1668739620,-256946.7044694837,-91417.96262602742,13089.27685070368],[1668741420,-258312.9350832856,-92388.64253407418,13100.44092857525],[1668743220,-259663.1192540169,-93353.41462434226,13110.79973527358],[1668745020,-260997.4524622215,-94312.31645933035,13120.36690390184],[1668746820,-262316.1249175637,-95265.38468041505,13129.15568127217],[1668748620,-263621.9188539753,-96206.52829756416,13135.11898502314],[1668750420,-264909.8724934342,-97147.88286050266,13142.33782614963],[1668752220,-266182.7062052384,-98083.50878544826,13148.81597976845],[1668754020,-267440.590994888,-99013.43924499914,13154.56529819497],[1668755820,-268683.6934942196,-99937.70659345828,13159.59731862781],[1668757620,-269912.176116613,-100856.3423853601,13163.92327536537],[1668759420,-271126.1972046115,-101769.3773929314,13167.55411143528],[1668761220,-272325.9111713567,-102676.8416232619,13170.50048967839],[1668763020,-273511.468635161,-103578.7643344132,13172.77280331299],[1668764820,-274683.0165487833,-104475.1740514156,13174.38118601403],[1668766620,-275840.6983222823,-105366.0985810617,13175.33552153194],[1668768420,-276984.6539413481,-106251.5650267438,13175.64545287899],[1668770220,-278115.0200798815,-107131.599802183,13175.32039110578],[1668772020,-279231.9302081334,-108006.2286448883,13174.36952369544],[1668773820,-280335.5146965568,-108875.4766292811,13172.801822587],[1668775620,-281425.9009145843,-109739.3681786673,13170.62605186102],[1668777420,-282503.2133263369,-110597.9270775397,13167.85077509359],[1668779220,-283567.5735814273,-111451.1764825411,13164.48436240794],[1668781020,-284619.1006031131,-112299.1389338043,13160.53499722759],[1668782820,-285657.9106719719,-113141.8363650082,13156.0106827605],[1668784620,-286684.1175071234,-113979.2901137115,13150.91924821583],[1668786420,-287697.8323436913,-114811.5209307268,13145.26835477744],[1668788220,-288699.1640076197,-115638.5489893912,13139.06550133842],[1668790020,-289688.2189874164,-116460.3938942022,13132.31803001677],[1668791820,-290665.1015033795,-117277.0746892458,13125.03313145638],[1668793620,-291629.9135740408,-118088.6098660315,13117.21784993172],[1668795420,-292582.7550803495,-118895.0173711327,13108.879088258],[1668797220,-293523.723827308,-119696.3146132494,13100.02361252506],[1668799020,-294452.9156035794,-120492.5184700934,13090.65805665696],[1668800820,-295370.4242387508,-121283.6452947008,13080.78892681207],[1668802620,-296276.3416587943,-122069.7109215959,13070.42260562487],[1668804420,-297170.7579393449,-122850.7306723713,13059.56535630773],[1668806220,-298053.7613573821,-123626.7193611411,13048.22332660549],[1668808020,-298925.4384408981,-124397.6912994195,13036.40255262799],[1668809820,-299785.8740171226,-125163.6603008719,13024.10896254655],[1668811620,-300635.1512589072,-125924.6396855048,13011.34838018377],[1668813420,-301473.351729805,-126680.6422837304,12998.12652847961],[1668815220,-302300.5554274525,-127431.6804398701,12984.44903286103],[1668817020,-303116.8408257876,-128177.766015539,12970.32142450083],[1668818820,-303922.2849156955,-128918.9103924716,12955.74914349154],[1668820620,-304716.9632446116,-129655.124475223,12940.73754191759],[1668822420,-305500.9499546742,-130386.4186933163,12925.29188685364],[1668824220,-306274.3178199457,-131112.8030032585,12909.4173632706],[1668826020,-307037.1382822935,-131834.2868900019,12893.11907687762],[1668827820,-307789.4814864345,-132550.8793682637,12876.40205687915],[1668829620,-308531.4163137493,-133262.5889832908,12859.2712586784],[1668831420,-309265.5700551358,-133961.7038554223,12852.18407601117],[1668833220,-309986.9463179132,-134663.5190893843,12834.45625303529],[1668835020,-310698.1135003465,-135360.4744859505,12816.32871812997],[1668836820,-311399.1357503142,-136052.5767145929,12797.8061519096],[1668838620,-312090.076108644,-136739.8319742822,12778.89317381151],[1668840420,-312770.9965375231,-137422.2459917685,12759.59434447566],[1668842220,-313441.9579489154,-138099.8240201583,12739.91416806263],[1668844020,-314103.0202315881,-138772.5708363967,12719.85709455646],[1668845820,-314754.2422781453,-139440.4907389615,12699.42752201346],[1668847620,-315395.6820108659,-140103.5875445526,12678.6297987966],[1668849420,-316027.3964075211,-140761.8645848896,12657.46822576935],[1668851220,-316649.4415261266,-141415.3247025077,12635.94705847677],[1668853020,-317261.8725295378,-142063.9702464459,12614.07050930154],[1668854820,-317864.7437093996,-142707.8030672709,12591.84274960309],[1668856620,-318458.1085097094,-143346.8245116286,12569.26791184308],[1668858420,-319042.0195499117,-143981.035416235,12546.35009170464],[1668860220,-319616.5286476496,-144610.4361013211,12523.09335020134],[1668862020,-320181.6868411163,-145235.0263634656,12499.50171578514],[1668863820,-320737.5444111059,-145854.8054678361,12475.57918645605],[1668865620,-321284.1509027401,-146469.772139762,12451.32973187693],[1668867420,-321821.5551469519,-147079.9245556488,12426.75729549469],[1668869220,-322349.8052817025,-147685.2603331621,12401.86579667762],[1668871020,-322868.9487730171,-148285.7765206816,12376.65913286595],[1668872820,-323379.0324358169,-148881.4695859497,12351.14118174846],[1668874620,-323880.1024546123,-149472.3354038831,12325.31580345979],[1668876420,-324372.2044041657,-150058.3692436093,12299.18684280914],[1668878220,-324855.3832698195,-150639.5657542749,12272.75813155306],[1668880020,-325329.6834681981,-151215.9189503611,12246.03349068906],[1668881820,-325795.1488673997,-151787.4221953741,12219.01673282237],[1668883620,-326247.5306424361,-152366.545906318,12173.30642585047],[1668885420,-326695.4011788947,-152928.4899824224,12145.48590885991],[1668887220,-327134.5655311199,-153485.5599591804,12117.38529532074],[1668889020,-327565.0655781262,-154037.7464190946,12089.00839664944],[1668890820,-327986.9427536246,-154585.0391959381,12060.3590287138],[1668892620,-328400.2380674123,-155127.4273512158,12031.4410145966],[1668894420,-328804.9921266598,-155664.899148524,12002.25818748],[1668896220,-329201.2451578059,-156197.4420265304,11972.81439361376],[1668898020,-329589.0370286423,-156725.042569854,11943.1134954154],[1668899820,-329968.4072709812,-157247.6864781376,11913.159374683],[1668901620,-330339.395103673,-157765.3585327835,11882.95593595738],[1668903420,-330702.0394563386,-158278.0425615919,11852.50711002227],[1668905220,-331056.3789935779,-158785.7214007075,11821.81685758183],[1668907020,-331402.4521400129,-159288.376854081,11790.88917310366],[1668908820,-331740.2971060704,-159785.989649965,11759.72808886047],[1668910620,-332069.9519145234,-160278.5393941555,11728.3376791967],[1668912420,-332391.4544282407,-160766.00452019,11696.72206499935],[1668914220,-332704.8423787879,-161248.3622356099,11664.88541844497],[1668916020,-333010.1533962341,-161725.5884642621,11632.83196801705],[1668917820,-333307.4250404232,-162197.6577846084,11600.56600380589],[1668919620,-333596.6948334086,-162664.5433629078,11568.09188316672],[1668921420,-333878.0002937366,-163126.2168817766,11535.41403670213],[1668923220,-334151.3789722095,-163582.648462781,11502.53697466511],[1668925020,-334416.8684897379,-164033.8065833084,11469.46529377093],[1668926820,-334674.5065771493,-164479.6579865579,11436.20368449033],[1668928620,-334925.3326707458,-164917.4241545298,11422.03246720923],[1668930420,-335167.3115607239,-165352.6881357313,11388.78502089318],[1668932220,-335401.5533655148,-165782.5341523982,11355.36159046191],[1668934020,-335628.0967073803,-166206.9209498405,11321.76729805019],[1668935820,-335846.9805572626,-166625.8049655338,11288.00740340755],[1668937620,-336058.2442925024,-167039.1401835536,11254.08731501625],[1668939420,-336262.3619189119,-167446.4382369714,11219.4473554922],[1668941220,-336458.5555180675,-167848.4469213742,11185.13108953926],[1668943020,-336647.2502182786,-168244.7523746299,11150.6719055432],[1668944820,-336828.4876877112,-168635.2970266897,11116.07594324728],[1668946620,-337002.1637848568,-169020.0181207277,11081.2945449636],[1668948420,-337167.1404572348,-169398.8366801174,11045.89089378613],[1668950220,-337324.848501023,-169771.515770345,11010.0413445564],[1668952020,-337475.2282623999,-170138.3103998668,10974.32627796059],[1668953820,-337618.3724391425,-170498.9999239465,10938.50892171145],[1668955620,-337754.3287815345,-170853.5020787899,10902.59708027165],[1668957420,-337883.1464809064,-171201.7292739497,10866.59892056335],[1668959220,-338004.8763209179,-171543.5881421684,10830.52300450733],[1668961020,-338119.5708438804,-171878.9790385128,10794.37832506126],[1668962820,-338227.284533845,-172207.7954815869,10758.17434625302],[1668964620,-338328.0740188005,-172529.9235287071,10721.9210477364],[1668966420,-338421.998294375,-172845.2410751391,10685.62897454546],[1668968220,-338509.1189719683,-173153.6170662935,10649.30929276643],[1668970020,-338589.5005547557,-173454.910609097,10612.97385204297],[1668971820,-338663.2107452174,-173748.9699668771,10576.63525595128],[1668973620,-338730.3207871774,-174035.6314179979,10540.30694176184],[1668975420,-338790.9058548437,-174314.7179568049,10504.00327010735],[1668977220,-338845.0454860082,-174586.0378090949,10467.73962733258],[1668979020,-338892.8240700364,-174849.3827277212,10431.5325425125],[1668980820,-338934.3314010028,-175104.5260287803,10395.39982138359],[1668982620,-338969.6633052301,-175351.220319879,10359.36070035572],[1668984420,-338998.9223560039,-175589.1948584241,10323.43602450351],[1668986220,-339022.2186899397,-175818.1524660403,10287.64845412009],[1668988020,-339039.6709433532,-176037.765903829,10252.02270569387],[1668989820,-339051.4073297087,-176247.6735919561,10216.58583441898],[1668991620,-339057.5668848505,-176447.4745220327,10181.36756739608],[1668993420,-339057.6219276507,-176637.1782280584,10148.65133601634],[1668995220,-339053.084751937,-176815.4209323054,10113.89892112196],[1668997020,-339043.4684832462,-176982.0500645115,10079.47420581753],[1668998820,-339028.9725145529,-177136.4319384327,10045.4223857757],[1669000620,-339009.8171930087,-177277.8468004104,10011.79428423356],[1669002420,-338986.2470481812,-177405.4720576802,9978.647385500175],[1669004220,-338958.5346293324,-177518.3611256896,9946.047121411058],[1669006020,-338926.9850780109,-177615.416434452,9914.068489456811],[1669007820,-338891.941587337,-177695.3545077294,9882.798112968616],[1669009620,-338853.7919132646,-177756.6601187048,9852.336898142326],[1669011420,-338812.9761070464,-177797.5250703477,9822.803512309423],[1669013220,-338769.9981398701,-177815.7451512084,9794.336021714415],[1669015020,-338725.2836786461,-177808.0654556744,9766.79042475541],[1669016820,-338679.8289536905,-177771.9347017785,9740.941145531448],[1669018620,-338634.2416876096,-177702.6866269426,9719.648760186079],[1669020420,-338589.2287102854,-177595.3659458388,9697.706554941695],[1669022220,-338545.8147897244,-177442.3061244607,9678.205331575744],[1669024020,-338504.9640025644,-177233.3164589675,9661.698982338698],[1669025820,-338470.0089327777,-176948.577472227,9650.451421262427],[1669027620,-338436.0021599155,-176575.247244255,9642.695588804963],[1669029420,-338402.8536383304,-176070.6934903884,9641.363330539083],[1669031220,-338358.2805376777,-175363.9868434361,9648.898378765341],[1669033020,-338244.091608683,-174295.3013469579,9668.55660612914],[1669034820,-337632.703810999,-172437.2165339309,9688.991786939798]]} | |
| \ No newline at end of file | ||
Packages/MoonriseKit/Tests/FeedsTests/LiveEventTests.swift added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Feeds | |
| 4 | ||
| 5 | @Test func decodesLiveCoverage() throws { | |
| 6 | let json = Data(""" | |
| 7 | {"event":{"id":"artemis-1","title":"Artemis I","horizonsId":"-1023","launch":"2022-11-16T06:47:44Z","end":"2022-12-11T17:40:00Z", | |
| 8 | "phases":[{"at":"2022-11-16T06:47:44Z","label":"Launch"},{"at":"2022-11-21T12:44:00Z","label":"Outbound lunar flyby"}]}, | |
| 9 | "at":"2022-11-21T12:00:00.000Z","points":[[1668588420,-3171.3,10537.8,1526.1],[1669031220,300000,200000,10000]]} | |
| 10 | """.utf8) | |
| 11 | let c = try #require(try LiveCoverage.decode(json)) | |
| 12 | #expect(c.event.title == "Artemis I") | |
| 13 | #expect(c.points.count == 2) | |
| 14 | #expect(c.phase.current?.label == "Launch") | |
| 15 | #expect(c.phase.next?.label == "Outbound lunar flyby") | |
| 16 | #expect(abs(c.distanceKm! - (300000.0 * 300000 + 200000 * 200000 + 10000 * 10000).squareRoot()) < 1e-6) | |
| 17 | #expect(try LiveCoverage.decode(Data(#"{"event":null}"#.utf8)) == nil) | |
| 18 | } | |
| 19 | ||
| 20 | @Test func decodesLiveArtemisReplay() throws { | |
| 21 | // GET /v1/live?at=2022-11-21T13:00:00Z, saved 2026-10-18. | |
| 22 | let url = try #require(Bundle.module.url(forResource: "Fixtures/live-artemis1", withExtension: "json")) | |
| 23 | let c = try #require(try LiveCoverage.decode(Data(contentsOf: url))) | |
| 24 | #expect(c.points.count > 200) | |
| 25 | #expect(c.phase.current?.label == "Outbound lunar flyby") | |
| 26 | } | |
Packages/MoonriseKit/Tests/SkyEngineTests/EclipsePathTests.swift +9
| @@ -45,3 +45,12 @@ private func km(_ a: (lat: Double, lon: Double), _ b: (lat: Double, lon: Double) | ||
| 45 | 45 | #expect(madrid.obscuration > 0.8 && madrid.obscuration < 0.95) |
| 46 | 46 | #expect(LocalEclipse.at(Observer(latitude: 41.88, longitude: -87.63), near: peak) == nil) // Chicago: night |
| 47 | 47 | } |
| 48 | ||
| 49 | @Test func moonMatchesHorizonsFrame() { | |
| 50 | // Artemis I from JPL Horizons (geocentric, J2000 ecliptic, km) at 12:47 UTC on 21 November 2022, | |
| 51 | // ten minutes before its closest approach, about 130 km above the Moon. | |
| 52 | let craft = SIMD3<Double>(-337_632.7, -172_437.2, 9_689.0) | |
| 53 | let moon = SkyEngine.geocentricMoonEcliptic(at: Date(timeIntervalSince1970: 1_669_034_820)) | |
| 54 | let d = ((craft - moon) * (craft - moon)).sum().squareRoot() | |
| 55 | #expect(d < 3_000) | |
| 56 | } | |
server/src/index.ts +34
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { send, type ApnsConfig } from "./apns"; |
| 2 | 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; |
| 3 | 3 | import { fetchElements } from "./celestrak"; |
| 4 | import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live"; | |
| 4 | 5 | import { fetchUpcoming, type Launch } from "./ll2"; |
| 5 | 6 | import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news"; |
| 6 | 7 | import { PRIVACY_HTML } from "./privacy"; |
| @@ -62,6 +63,7 @@ export default { | ||
| 62 | 63 | return new Response(PRIVACY_HTML, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" } }); |
| 63 | 64 | if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); |
| 64 | 65 | if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now); |
| 66 | if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now); | |
| 65 | 67 | if (request.method === "GET" && url.pathname === "/v1/satellites") { |
| 66 | 68 | const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'celestrak' AND ok = 1").first<{ fetched_at: string; body: string }>(); |
| 67 | 69 | return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] }); |
| @@ -113,6 +115,38 @@ async function sendTest(env: Env, token: string, now: Date): Promise<Response> { | ||
| 113 | 115 | return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502); |
| 114 | 116 | } |
| 115 | 117 | |
| 118 | /** | |
| 119 | * The live event at `?at=` (default now) and its geocentric ecliptic trajectory up to then: | |
| 120 | * points are [unix seconds, x, y, z] km. The cached trajectory is refreshed from Horizons at most | |
| 121 | * every 10 minutes while the event runs. | |
| 122 | */ | |
| 123 | async function live(env: Env, url: URL, now: Date): Promise<Response> { | |
| 124 | const atParam = url.searchParams.get("at"); | |
| 125 | const at = atParam ? new Date(atParam) : now; | |
| 126 | if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400); | |
| 127 | const event = activeEvent(at); | |
| 128 | if (!event) return json({ event: null }); | |
| 129 | ||
| 130 | const key = `live:${event.id}`; | |
| 131 | const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = ?1 AND ok = 1").bind(key).first<{ fetched_at: string; body: string }>(); | |
| 132 | let points: number[][] = row ? JSON.parse(row.body) : []; | |
| 133 | const covered = (points.at(-1)?.[0] ?? 0) * 1000; | |
| 134 | const wanted = Math.min(now.getTime(), Date.parse(event.end)) - 2 * STEP_MINUTES * 60_000; | |
| 135 | const stale = !row || points.length === 0 || now.getTime() - Date.parse(row.fetched_at) > 10 * 60_000; | |
| 136 | if (covered < wanted && stale) { | |
| 137 | try { | |
| 138 | const fresh = await fetchTrajectory(event, now); | |
| 139 | if (fresh.length === 0) throw new Error("no Horizons data"); | |
| 140 | points = fresh; | |
| 141 | await 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") | |
| 142 | .bind(key, now.toISOString(), JSON.stringify(points)).run(); | |
| 143 | } catch (e) { | |
| 144 | console.error("horizons", String(e)); | |
| 145 | } | |
| 146 | } | |
| 147 | return json({ event, at: at.toISOString(), points: points.filter((p) => p[0] * 1000 <= at.getTime()) }); | |
| 148 | } | |
| 149 | ||
| 116 | 150 | async function launches(env: Env, now: Date): Promise<Response> { |
| 117 | 151 | const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); |
| 118 | 152 | const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); |
server/src/live.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | // Live coverage of a crewed or lunar mission: its trajectory from JPL Horizons, cached while the event runs. | |
| 2 | // To cover a new mission, add it to EVENTS (Horizons ID, window, phases) and deploy. | |
| 3 | ||
| 4 | export interface LiveEvent { | |
| 5 | id: string; | |
| 6 | title: string; | |
| 7 | /** Horizons COMMAND, e.g. "-1023" for Artemis I. */ | |
| 8 | horizonsId: string; | |
| 9 | /** ISO 8601 UTC. */ | |
| 10 | launch: string; | |
| 11 | end: string; | |
| 12 | phases: { at: string; label: string }[]; | |
| 13 | } | |
| 14 | ||
| 15 | export const EVENTS: LiveEvent[] = [ | |
| 16 | { | |
| 17 | // Past mission, kept for testing with /v1/live?at=2022-11-21T12:00:00Z. | |
| 18 | id: "artemis-1", title: "Artemis I", horizonsId: "-1023", | |
| 19 | launch: "2022-11-16T06:47:44Z", end: "2022-12-11T17:40:00Z", | |
| 20 | phases: [ | |
| 21 | { at: "2022-11-16T06:47:44Z", label: "Launch" }, | |
| 22 | { at: "2022-11-21T12:44:00Z", label: "Outbound lunar flyby" }, | |
| 23 | { at: "2022-11-25T21:52:00Z", label: "Distant retrograde orbit" }, | |
| 24 | { at: "2022-12-01T21:53:00Z", label: "Leaves lunar orbit" }, | |
| 25 | { at: "2022-12-05T16:43:00Z", label: "Return lunar flyby" }, | |
| 26 | { at: "2022-12-11T17:40:00Z", label: "Splashdown" }, | |
| 27 | ], | |
| 28 | }, | |
| 29 | ]; | |
| 30 | ||
| 31 | export const STEP_MINUTES = 30; | |
| 32 | ||
| 33 | /** The event whose window contains `at`, if any. */ | |
| 34 | export function activeEvent(at: Date, events = EVENTS): LiveEvent | undefined { | |
| 35 | return events.find((e) => Date.parse(e.launch) <= at.getTime() && at.getTime() <= Date.parse(e.end)); | |
| 36 | } | |
| 37 | ||
| 38 | /** Horizons rows from a CSV vector table (VEC_TABLE 1): [unix seconds, x, y, z] km. */ | |
| 39 | export function parseVectors(result: string): number[][] { | |
| 40 | const start = result.indexOf("$$SOE"), stop = result.indexOf("$$EOE"); | |
| 41 | if (start < 0 || stop < 0) return []; | |
| 42 | return result.slice(start + 5, stop).trim().split("\n").map((line) => { | |
| 43 | const f = line.split(",").map((s) => s.trim()); | |
| 44 | const jd = Number(f[0]); | |
| 45 | return [Math.round((jd - 2440587.5) * 86400), Number(f[2]), Number(f[3]), Number(f[4])]; | |
| 46 | }).filter((r) => r.every(Number.isFinite)); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * The limit in Horizons' "No ephemeris for target ... prior to A.D. 2022-NOV-16 08:46:00.3328 TDB" | |
| 51 | * (or "after A.D. ...") reply. | |
| 52 | */ | |
| 53 | export function ephemerisLimit(result: string, which: "prior to" | "after"): Date | undefined { | |
| 54 | const m = result.match(new RegExp(`${which} A\\.D\\. (\\d{4})-([A-Z]{3})-(\\d{2}) (\\d{2}):(\\d{2})`)); | |
| 55 | if (!m) return undefined; | |
| 56 | const month = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"].indexOf(m[2]); | |
| 57 | return new Date(Date.UTC(Number(m[1]), month, Number(m[3]), Number(m[4]), Number(m[5]))); | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Geocentric positions on J2000 ecliptic axes from launch to `until`, every STEP_MINUTES. Horizons data | |
| 62 | * for a spacecraft usually starts after launch and may end before the event does; the request is | |
| 63 | * narrowed to what Horizons says it has. | |
| 64 | */ | |
| 65 | export async function fetchTrajectory(e: LiveEvent, until: Date, from = new Date(e.launch), to = new Date(e.end)): Promise<number[][]> { | |
| 66 | const start = from; | |
| 67 | const stop = new Date(Math.min(until.getTime(), to.getTime())); | |
| 68 | if (stop <= start) return []; | |
| 69 | const fmt = (d: Date) => d.toISOString().slice(0, 16).replace("T", " "); | |
| 70 | const params = new URLSearchParams({ | |
| 71 | format: "json", COMMAND: `'${e.horizonsId}'`, EPHEM_TYPE: "'VECTORS'", CENTER: "'500@399'", REF_PLANE: "'ECLIPTIC'", | |
| 72 | START_TIME: `'${fmt(start)}'`, STOP_TIME: `'${fmt(stop)}'`, STEP_SIZE: `'${STEP_MINUTES}m'`, | |
| 73 | VEC_TABLE: "'1'", OUT_UNITS: "'KM-S'", CSV_FORMAT: "'YES'", OBJ_DATA: "'NO'", MAKE_EPHEM: "'YES'", | |
| 74 | }); | |
| 75 | const res = await fetch(`https://ssd.jpl.nasa.gov/api/horizons.api?${params}`, { headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" } }); | |
| 76 | if (res.status !== 200) throw new Error(`Horizons ${res.status}`); | |
| 77 | const result = ((await res.json()) as { result: string }).result; | |
| 78 | const first = ephemerisLimit(result, "prior to"); | |
| 79 | if (first && first > from) return fetchTrajectory(e, until, new Date(first.getTime() + 60_000), to); | |
| 80 | const last = ephemerisLimit(result, "after"); | |
| 81 | if (last && last < stop) return fetchTrajectory(e, until, from, new Date(last.getTime() - 60_000)); | |
| 82 | return parseVectors(result); | |
| 83 | } | |
server/test/live.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | import assert from "node:assert/strict"; | |
| 2 | import { test } from "node:test"; | |
| 3 | import { activeEvent, ephemerisLimit, parseVectors } from "../src/live.ts"; | |
| 4 | ||
| 5 | test("finds the event covering a time", () => { | |
| 6 | assert.equal(activeEvent(new Date("2022-11-21T12:00:00Z"))?.id, "artemis-1"); | |
| 7 | assert.equal(activeEvent(new Date("2022-12-12T00:00:00Z")), undefined); | |
| 8 | assert.equal(activeEvent(new Date("2026-10-18T00:00:00Z")), undefined); | |
| 9 | }); | |
| 10 | ||
| 11 | test("parses a Horizons CSV vector table", () => { | |
| 12 | // Excerpt of a Horizons VECTORS result for Artemis I, 2022-11-20, VEC_TABLE 1. | |
| 13 | const result = `header\n$$SOE\n2459903.500000000, A.D. 2022-Nov-20 00:00:00.0000, -3.304975631098540E+05, -1.579881350142587E+05, 1.186979134120867E+04,\n2459903.750000000, A.D. 2022-Nov-20 06:00:00.0000, -3.342673908331381E+05, -1.637787996887478E+05, 1.148822953490770E+04,\n$$EOE\nfooter`; | |
| 14 | const rows = parseVectors(result); | |
| 15 | assert.equal(rows.length, 2); | |
| 16 | assert.deepEqual(rows[0], [1668902400, -330497.563109854, -157988.1350142587, 11869.79134120867]); | |
| 17 | assert.equal(rows[1][0] - rows[0][0], 6 * 3600); | |
| 18 | }); | |
| 19 | ||
| 20 | test("reads the start of Horizons data from its error", () => { | |
| 21 | const msg = 'No ephemeris for target "Artemis 1 (spacecraft)" prior to A.D. 2022-NOV-16 08:46:00.3328 TDB'; | |
| 22 | assert.equal(ephemerisLimit(msg, "prior to")?.toISOString(), "2022-11-16T08:46:00.000Z"); | |
| 23 | assert.equal(ephemerisLimit(msg, "after"), undefined); | |
| 24 | const end = 'No ephemeris for target "Artemis 1 (spacecraft)" after A.D. 2022-DEC-11 17:21:01.8003 TDB'; | |
| 25 | assert.equal(ephemerisLimit(end, "after")?.toISOString(), "2022-12-11T17:21:00.000Z"); | |
| 26 | assert.equal(ephemerisLimit("$$SOE ... $$EOE", "prior to"), undefined); | |
| 27 | }); | |