krz/moonrise

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

Commit e3c1b67336

e3c1b67336bf51cbea9dc354edba47d8d0382856

parent: 7efb6a812b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-18 22:13 UTC

Add live mission coverage with a Live Activity

Layout: unified · split

App/Shared/EventActivity.swift added +16
@@ -0,0 +1,16 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity for a mission covered live. Shared by the app and the widget extension.
5struct 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 @@
1import ActivityKit
2import Feeds
3import SkyEngine
4import 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.
9struct 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.
104struct 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 Text("Solar system").font(.title.weight(.medium)) 26 Text("Solar system").font(.title.weight(.medium))
27 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16) 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 // Labels in this dense diagram stop growing at xxLarge; the table below scales fully. 31 // Labels in this dense diagram stop growing at xxLarge; the table below scales fully.
30 OrreryView(date: date, ink: ink, accent: accent) 32 OrreryView(date: date, ink: ink, accent: accent)
31 .dynamicTypeSize(...DynamicTypeSize.xxLarge) 33 .dynamicTypeSize(...DynamicTypeSize.xxLarge)
App/Widgets/EventLiveActivity.swift added +49
@@ -0,0 +1,49 @@
1import ActivityKit
2import SwiftUI
3import WidgetKit
4
5struct 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 PlanetsWidget() 11 PlanetsWidget()
12 #if os(iOS) 12 #if os(iOS)
13 LaunchLiveActivity() 13 LaunchLiveActivity()
14 EventLiveActivity()
14 #endif 15 #endif
15 } 16 }
16} 17}
PLAN.org +5 −1
@@ -422,7 +422,11 @@ Sky, System, Sun, Launches, Library core (above).
422 months, 5 to 8 stops each, in Library/Resources/tours. Mission stories 422 months, 5 to 8 stops each, in Library/Resources/tours. Mission stories
423 done for Voyager 1 and 2, Pioneer 10 and 11 and New Horizons, on the orrery. 423 done for Voyager 1 and 2, Pioneer 10 and 11 and New Horizons, on the orrery.
424- Live coverage of major events (e.g. an Artemis landing) on the orrery 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- Maybe: smart-telescope pointing (Seestar and similar). 430- Maybe: smart-telescope pointing (Seestar and similar).
427 431
428* risks 432* risks
Packages/MoonriseKit/Sources/Feeds/LiveEvent.swift added +64
@@ -0,0 +1,64 @@
1import Foundation
2
3/// A mission covered live by the backend (GET /v1/live): its phases and geocentric trajectory.
4public 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 return ecliptic(SIMD3(v.x, v.y, v.z)) 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 /// J2000 equatorial to J2000 ecliptic axes. 167 /// J2000 equatorial to J2000 ecliptic axes.
160 static func ecliptic(_ v: SIMD3<Double>) -> SIMD3<Double> { 168 static func ecliptic(_ v: SIMD3<Double>) -> SIMD3<Double> {
161 let e = 23.4392911 * Double.pi / 180 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 @@
1import Foundation
2import 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 #expect(madrid.obscuration > 0.8 && madrid.obscuration < 0.95) 45 #expect(madrid.obscuration > 0.8 && madrid.obscuration < 0.95)
46 #expect(LocalEclipse.at(Observer(latitude: 41.88, longitude: -87.63), near: peak) == nil) // Chicago: night 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 @@
1import { send, type ApnsConfig } from "./apns"; 1import { send, type ApnsConfig } from "./apns";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; 2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchElements } from "./celestrak"; 3import { fetchElements } from "./celestrak";
4import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live";
4import { fetchUpcoming, type Launch } from "./ll2"; 5import { fetchUpcoming, type Launch } from "./ll2";
5import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news"; 6import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news";
6import { PRIVACY_HTML } from "./privacy"; 7import { PRIVACY_HTML } from "./privacy";
@@ -62,6 +63,7 @@ export default {
62 return new Response(PRIVACY_HTML, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" } }); 63 return new Response(PRIVACY_HTML, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" } });
63 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); 64 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now);
64 if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now); 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 if (request.method === "GET" && url.pathname === "/v1/satellites") { 67 if (request.method === "GET" && url.pathname === "/v1/satellites") {
66 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 }>(); 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 return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] }); 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 return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502); 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 */
123async 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
116async function launches(env: Env, now: Date): Promise<Response> { 150async function launches(env: Env, now: Date): Promise<Response> {
117 const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); 151 const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
118 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); 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
4export 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
15export 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
31export const STEP_MINUTES = 30;
32
33/** The event whose window contains `at`, if any. */
34export 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. */
39export 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 */
53export 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 */
65export 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 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { activeEvent, ephemerisLimit, parseVectors } from "../src/live.ts";
4
5test("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
11test("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
20test("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});