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 05f6b6597a

05f6b6597a468cb5e551b84fcdbb8ef06861f865

parent: 7efb6a812b

Unsigned

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

v3: Live mission coverage (!40)

While a configured mission runs, the System tab shows a LIVE section: the craft's path between Earth and the Moon, mission time, phase and next phase, distances from Earth's and the Moon's centres, and Follow on the Lock Screen (a Live Activity, updated while the app runs).

* Server (deployed)
- =GET /v1/live[?at=ISO]=: the event running at =at= (default now) and its geocentric J2000-ecliptic trajectory up to then, [unix s, x, y, z] km every 30 min from JPL Horizons. Cached in feed_snapshots; refetched at most every 10 min while incomplete. No new cron.
- Horizons often has no data for the first hour or last minutes of a mission; its "No ephemeris prior to/after" reply is read and the request narrowed.
- =EVENTS= in server/src/live.ts holds Artemis I (2022) as a replay for testing. Adding a mission is an entry there plus a deploy.

* App
- =LiveCoverage= (Feeds), =SkyEngine.geocentricMoonEcliptic=, =LiveSection=, =EventLiveActivity=.
- Debug builds replay with the user default =liveReplayAt=.
- Tests: Horizons parsing and limits, event lookup, response decoding (saved replay fixture), and the Moon from Astronomy Engine within 3,000 km of Artemis I at closest approach (same frame as Horizons).

* Checked
- Replay at 2022-11-21 13:00 UTC on the simulator: 379,241 km from Earth, 2,563 km from the Moon's centre, phase "Outbound lunar flyby".
- Not checked: the Live Activity on a device; there is no live event now.

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});