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 63e0316d09

63e0316d09c338c6828c57ea29a035d1f17a0e35

parent: 3b8c40e96a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:29 UTC

Eclipse engine: IERS ΔT, contacts to 0.1 s, site time zone, precise location

- ΔT from IERS Bulletin A (15 Oct 2026) between 2020 and Oct 2027, set with
  Astronomy_SetDeltaTFunction and fading to Espenak–Meeus by 2010 and 2050.
  69.38 s on 2 Aug 2027 instead of 76.0 s; contacts move about 10 s later.
- Local contacts bisected to 0.05 s with NASA's Moon radii (k = 0.2725076
  partial, 0.272281 total). Tests against NASA's calculator for Tarifa,
  Tangier and Luxor.
- Eclipse screen: times in the site's time zone (Apple Maps) and UTC,
  best-accuracy continuous location with altitude unless a city is chosen.

Layout: unified · split

App/Sources/EclipseLocation.swift added +61
@@ -0,0 +1,61 @@
1import CoreLocation
2import MapKit
3import Observation
4import SkyEngine
5
6/// Continuous best-accuracy fixes while an eclipse screen is open: near the edge of the path a contact moves
7/// about 4 s per kilometre.
8@MainActor
9@Observable
10final class EclipseLocation: NSObject, CLLocationManagerDelegate {
11 struct Fix: Equatable {
12 var observer: Observer
13 /// Horizontal accuracy, metres.
14 var accuracy: Double
15 }
16
17 private(set) var fix: Fix?
18 private let manager = CLLocationManager()
19
20 override init() {
21 super.init()
22 manager.delegate = self
23 manager.desiredAccuracy = kCLLocationAccuracyBest
24 manager.distanceFilter = 5
25 }
26
27 func start() {
28 manager.requestWhenInUseAuthorization()
29 manager.startUpdatingLocation()
30 }
31
32 func stop() {
33 manager.stopUpdatingLocation()
34 }
35
36 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
37 guard let l = locations.last, l.horizontalAccuracy >= 0 else { return }
38 let fix = Fix(
39 observer: Observer(latitude: l.coordinate.latitude, longitude: l.coordinate.longitude, height: l.verticalAccuracy >= 0 ? l.ellipsoidalAltitude : 0),
40 accuracy: l.horizontalAccuracy
41 )
42 Task { @MainActor in self.fix = fix }
43 }
44
45 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
46 Task { @MainActor in log.error("Eclipse location failed: \(error.localizedDescription, privacy: .public)") }
47 }
48
49 /// The time zone at `observer` from Apple Maps, or nil offline.
50 static func timeZone(at observer: Observer) async -> TimeZone? {
51 guard let request = MKReverseGeocodingRequest(location: CLLocation(latitude: observer.latitude, longitude: observer.longitude)) else { return nil }
52 return try? await request.mapItems.first?.timeZone
53 }
54}
55
56/// "UTC+2" for a zone's offset at `date`.
57func utcOffset(_ zone: TimeZone, at date: Date) -> String {
58 let s = zone.secondsFromGMT(for: date)
59 let h = abs(s) / 3600, m = abs(s) % 3600 / 60
60 return "UTC\(s < 0 ? "−" : "+")\(h)\(m == 0 ? "" : String(format: ":%02d", m))"
61}
App/Sources/EclipseScreen.swift +50 −11
@@ -10,8 +10,15 @@ struct EclipseScreen: View {
1010 @State private var path: EclipsePath?
1111 @State private var local: LocalEclipse?
1212 @State private var loaded = false
13 @State private var location = EclipseLocation()
14 /// Time zone at the site, from Apple Maps; times are also given in UTC, and only in UTC when this is unknown.
15 @State private var zone: TimeZone?
16 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
1317 @Environment(\.dismiss) private var dismiss
1418
19 /// The latest precise fix, or the place the screen was opened for (a chosen city, or the last coarse fix).
20 private var site: Observer { locationMode == "city" ? observer : location.fix?.observer ?? observer }
21
1522 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
1623 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
1724
@@ -20,7 +27,7 @@ struct EclipseScreen: View {
2027 ScrollView {
2128 VStack(alignment: .leading, spacing: 16) {
2229 if let path {
23 EclipseMap(path: path, observer: observer, ink: ink, accent: accent)
30 EclipseMap(path: path, observer: site, ink: ink, accent: accent)
2431 .aspectRatio(1.6, contentMode: .fit)
2532 .accessibilityLabel("Map of the path of the eclipse")
2633 facts(path)
@@ -41,17 +48,26 @@ struct EclipseScreen: View {
4148 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
4249 }
4350 .task {
44 let date = date, observer = observer
45 (path, local) = await Task.detached { (EclipsePath.compute(near: date), LocalEclipse.at(observer, near: date)) }.value
51 let date = date
52 path = await Task.detached { EclipsePath.compute(near: date) }.value
4653 loaded = true
4754 }
55 .task(id: site) {
56 let date = date, site = site
57 local = await Task.detached { LocalEclipse.at(site, near: date) }.value
58 }
59 .task(id: [(site.latitude * 10).rounded(), (site.longitude * 10).rounded()]) {
60 if let z = await EclipseLocation.timeZone(at: site) { zone = z }
61 }
62 .onAppear { if locationMode != "city" { location.start() } }
63 .onDisappear { location.stop() }
4864 }
4965
5066 private func facts(_ p: EclipsePath) -> some View {
5167 let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) }
5268 var rows: [(String, String)] = [
53 ("Date", p.peak.formatted(date: .long, time: .omitted)),
54 ("Greatest eclipse", "\(p.peak.formatted(date: .omitted, time: .shortened)) at \(latLon(p.greatest))"),
69 ("Date", p.peak.formatted(Date.FormatStyle(date: .long, time: .omitted, timeZone: .gmt))),
70 ("Greatest eclipse", "\(p.peak.formatted(Date.FormatStyle(date: .omitted, time: .shortened, timeZone: .gmt))) UTC at \(latLon(p.greatest))"),
5571 ]
5672 if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) }
5773 if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) }
@@ -65,18 +81,33 @@ struct EclipseScreen: View {
6581 @ViewBuilder
6682 private var here: some View {
6783 VStack(alignment: .leading, spacing: 6) {
68 Text("From \(placeName)").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
84 Text(placeLabel).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
6985 if let l = local {
7086 let central = l.kind == "Total" ? "Totality" : "Annular phase"
87 let zones = [zone, .gmt].compactMap { $0 }
7188 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
72 GridRow { Text("Begins").dimmed(0.7); Text(time(l.partialBegin)) }
89 GridRow {
90 Text("")
91 if let zone { Text("Local, \(utcOffset(zone, at: l.peak))").dimmed(0.6) }
92 Text("UTC").dimmed(0.6)
93 }
94 .font(.caption)
95 GridRow { Text("Begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialBegin, $0)) } }
7396 if let b = l.centralBegin, let e = l.centralEnd {
74 GridRow { Text(central).dimmed(0.7); Text("\(time(b))–\(time(e)), \(minutes(e.timeIntervalSince(b)))") }
97 GridRow { Text("\(central) begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(b, $0)) } }
98 GridRow { Text("\(central) ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(e, $0)) } }
7599 }
76 GridRow { Text("Maximum").dimmed(0.7); Text("\(time(l.peak)), \(Int((l.obscuration * 100).rounded()))% of the Sun covered") }
77 GridRow { Text("Ends").dimmed(0.7); Text(time(l.partialEnd)) }
100 GridRow { Text("Maximum").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.peak, $0)) } }
101 GridRow { Text("Ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialEnd, $0)) } }
78102 }
79103 .font(.subheadline.monospacedDigit())
104 if let b = l.centralBegin, let e = l.centralEnd {
105 Text("\(central) lasts \(minutes(e.timeIntervalSince(b))).").font(.subheadline)
106 }
107 Text("\(Int((l.obscuration * 100).rounded()))% of the Sun covered at maximum.").font(.subheadline)
108 if zone == nil {
109 Text("Local time at this site needs a connection to look up, so times are in UTC.").font(.footnote).dimmed(0.7)
110 }
80111 if l.peakAltitude < 10 {
81112 Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7)
82113 }
@@ -88,7 +119,15 @@ struct EclipseScreen: View {
88119 }
89120 }
90121
91 private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .standard) }
122 private var placeLabel: String {
123 if locationMode != "city", let fix = location.fix { return "From your location, ±\(Int(fix.accuracy.rounded())) m" }
124 return "From \(placeName)"
125 }
126
127 /// To the nearest second in `zone`.
128 private func time(_ d: Date, _ zone: TimeZone) -> String {
129 Date(timeIntervalSinceReferenceDate: d.timeIntervalSinceReferenceDate.rounded()).formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: zone))
130 }
92131
93132 private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" }
94133
Packages/MoonriseKit/Sources/SkyEngine/DeltaT.swift added +40
@@ -0,0 +1,40 @@
1import CAstronomy
2import Foundation
3
4/// ΔT = TT − UT1, seconds. Measured and predicted IERS values from 2020, with the difference from
5/// Astronomy Engine's Espenak–Meeus polynomial fading to zero by 2010 and by 2050, so other eras are unchanged.
6enum DeltaT {
7 /// Decimal year and 32.184 s + (TAI − UTC = 37 s) − (UT1 − UTC), from IERS finals2000A as of
8 /// Bulletin A of 15 October 2026. Later entries are its predictions.
9 static let table: [(year: Double, seconds: Double)] = [
10 (2020.0, 69.361), (2021.0, 69.359), (2022.0, 69.295), (2023.0, 69.204), (2024.0, 69.175), (2025.0, 69.138),
11 (2026.0, 69.110), (2026.789, 69.221), (2027.0, 69.308), (2027.584, 69.382), (2027.748, 69.371),
12 ]
13 static let fadeFrom = 2010.0, fadeTo = 2050.0
14
15 /// `ut`: days since J2000, as Astronomy Engine passes it.
16 static func seconds(ut: Double) -> Double {
17 let model = Astronomy_DeltaT_EspenakMeeus(ut)
18 // The polynomial's own year scale.
19 let year = 2000 + (ut - 14) / 365.24217
20 guard year > fadeFrom, year < fadeTo else { return model }
21 let first = table[0], last = table[table.count - 1]
22 if year < first.year {
23 return model + (first.seconds - polynomial(first.year)) * (year - fadeFrom) / (first.year - fadeFrom)
24 }
25 if year > last.year {
26 return model + (last.seconds - polynomial(last.year)) * (fadeTo - year) / (fadeTo - last.year)
27 }
28 let i = table.firstIndex { $0.year >= year }!
29 guard i > 0 else { return first.seconds }
30 let a = table[i - 1], b = table[i]
31 return a.seconds + (b.seconds - a.seconds) * (year - a.year) / (b.year - a.year)
32 }
33
34 static func polynomial(_ year: Double) -> Double {
35 Astronomy_DeltaT_EspenakMeeus((year - 2000) * 365.24217 + 14)
36 }
37
38 /// Astronomy Engine reads ΔT through a global function pointer; `SkyEngine.time` touches this first.
39 static let installed: Void = Astronomy_SetDeltaTFunction { DeltaT.seconds(ut: $0) }
40}
Packages/MoonriseKit/Sources/SkyEngine/EclipsePath.swift +47 −4
@@ -141,14 +141,57 @@ public struct LocalEclipse: Sendable {
141141 guard abs(peak.timeIntervalSince(date)) < 6 * 3600 else { return nil }
142142 guard l.partial_begin.altitude > 0 || l.peak.altitude > 0 || l.partial_end.altitude > 0 else { return nil }
143143 let central = l.kind == ECLIPSE_TOTAL || l.kind == ECLIPSE_ANNULAR
144 let partial = { (s: Shadow) in s.p - s.r }, inner = { (s: Shadow) in abs(s.k) - s.r }
144145 return LocalEclipse(
145146 kind: SkyEngine.eclipseKind(l.kind), obscuration: l.obscuration,
146 partialBegin: SkyEngine.date(l.partial_begin.time),
147 centralBegin: central ? SkyEngine.date(l.total_begin.time) : nil,
147 partialBegin: refine(SkyEngine.date(l.partial_begin.time), observer, partial),
148 centralBegin: central ? refine(SkyEngine.date(l.total_begin.time), observer, inner) : nil,
148149 peak: peak,
149 centralEnd: central ? SkyEngine.date(l.total_end.time) : nil,
150 partialEnd: SkyEngine.date(l.partial_end.time),
150 centralEnd: central ? refine(SkyEngine.date(l.total_end.time), observer, inner) : nil,
151 partialEnd: refine(SkyEngine.date(l.partial_end.time), observer, partial),
151152 peakAltitude: l.peak.altitude
152153 )
153154 }
155
156 /// Moon radii for the partial and total contacts, km: k = 0.2725076 and the smaller k = 0.272281 that NASA's
157 /// eclipse predictions use for totality to allow for valleys on the limb, times 6378.137 km.
158 /// Astronomy Engine uses 1737.4 km for both.
159 static let moonRadiusPartialKm = 1_738.09, moonRadiusTotalKm = 1_736.64
160
161 /// The Moon's shadow at the observer, as Astronomy Engine computes it for local eclipses: the observer's
162 /// distance from the shadow axis and the umbra and penumbra radii at the observer, km.
163 struct Shadow {
164 var r: Double
165 var k: Double
166 var p: Double
167 }
168
169 static func shadow(at date: Date, from observer: Observer) -> Shadow {
170 var t = SkyEngine.time(date)
171 let o = Astronomy_ObserverVector(&t, observer.astro, EQUATOR_J2000)
172 let s = Astronomy_GeoVector(BODY_SUN, t, ABERRATION)
173 let m = Astronomy_GeoMoon(t)
174 let target = SIMD3(o.x - m.x, o.y - m.y, o.z - m.z), dir = SIMD3(m.x - s.x, m.y - s.y, m.z - s.z)
175 let u = dot(dir, target) / dot(dir, dir)
176 let sun = EclipsePath.sunRadiusKm
177 return Shadow(
178 r: length(dir * u - target) * EclipsePath.kmPerAU,
179 k: sun - (1 + u) * (sun - moonRadiusTotalKm),
180 p: -sun + (1 + u) * (sun + moonRadiusPartialKm)
181 )
182 }
183
184 /// Moves a contact found to Astronomy Engine's 1 s search tolerance, with its single Moon radius, to the radii above,
185 /// to 0.05 s, by bisecting where `edge` changes sign.
186 static func refine(_ near: Date, _ observer: Observer, _ edge: (Shadow) -> Double) -> Date {
187 var a = near.addingTimeInterval(-5), b = near.addingTimeInterval(5)
188 var fa = edge(shadow(at: a, from: observer))
189 guard fa * edge(shadow(at: b, from: observer)) < 0 else { return near }
190 while b.timeIntervalSince(a) > 0.05 {
191 let mid = a.addingTimeInterval(b.timeIntervalSince(a) / 2)
192 let f = edge(shadow(at: mid, from: observer))
193 if (f < 0) == (fa < 0) { a = mid; fa = f } else { b = mid }
194 }
195 return a.addingTimeInterval(b.timeIntervalSince(a) / 2)
196 }
154197}
Packages/MoonriseKit/Sources/SkyEngine/SkyEngine.swift +2 −1
@@ -71,8 +71,9 @@ public enum SkyEngine {
7171 static let lock = NSRecursiveLock()
7272
7373 static func time(_ date: Date) -> astro_time_t {
74 DeltaT.installed
7475 // Days since J2000 (2000-01-01 12:00 UTC).
75 Astronomy_TimeFromDays((date.timeIntervalSince1970 - 946_728_000) / 86_400)
76 return Astronomy_TimeFromDays((date.timeIntervalSince1970 - 946_728_000) / 86_400)
7677 }
7778
7879 static func check(_ status: astro_status_t) throws {
Packages/MoonriseKit/Tests/SkyEngineTests/DeltaTTests.swift added +28
@@ -0,0 +1,28 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private func ut(_ year: Double) -> Double { (year - 2000) * 365.24217 + 14 }
6
7@Test func deltaTFollowsIERS() {
8 // IERS Bulletin A, 15 October 2026: UT1 − UTC = −0.0367 s that day and −0.198 s predicted for 2 August 2027.
9 let now = SkyEngine.time(Date(timeIntervalSince1970: 1_792_022_400))
10 #expect(abs((now.tt - now.ut) * 86_400 - 69.221) < 0.01)
11 let eclipse = SkyEngine.time(Date(timeIntervalSince1970: 1_817_201_220))
12 #expect(abs((eclipse.tt - eclipse.ut) * 86_400 - 69.382) < 0.01)
13}
14
15@Test func deltaTUnchangedOutsideModernEra() {
16 for year in [-3000.0, -500, 1000, 1700, 1900, 2000, 2010, 2050, 2100, 3000] {
17 #expect(DeltaT.seconds(ut: ut(year)) == DeltaT.polynomial(year))
18 }
19}
20
21@Test func deltaTHasNoSteps() {
22 var previous = DeltaT.seconds(ut: ut(2005))
23 for month in 1...(12 * 50) {
24 let next = DeltaT.seconds(ut: ut(2005 + Double(month) / 12))
25 #expect(abs(next - previous) < 0.25)
26 previous = next
27 }
28}
Packages/MoonriseKit/Tests/SkyEngineTests/EclipsePathTests.swift +30
@@ -46,6 +46,36 @@ private func km(_ a: (lat: Double, lon: Double), _ b: (lat: Double, lon: Double)
4646 #expect(LocalEclipse.at(Observer(latitude: 41.88, longitude: -87.63), near: peak) == nil) // Chicago: night
4747}
4848
49/// Contacts for 2 August 2027 from NASA's local circumstances calculator for this eclipse (Espenak and Jubier, SEcirc.js on
50/// eclipse.gsfc.nasa.gov/SEgoogle/SEgoogle2001/SE2027Aug02Tgoogle.html, VSOP87/ELP2000-85 Besselian elements), run with
51/// ΔT = 69.38 s instead of the page's 71.7 s. UT seconds after midnight: C1, C2, C3, C4.
52private let nasa2027: [(name: String, lat: Double, lon: Double, contacts: [Double])] = [
53 ("Tarifa", 36.0143, -5.6044, [27_652.7, 31_508.2, 31_786.9, 36_044.7]), // 07:40:52.7, 08:45:08.2, 08:49:46.9, 10:00:44.7
54 ("Tangier", 35.7595, -5.8340, [27_635.1, 31_481.1, 31_771.6, 36_023.0]), // 07:40:35.1, 08:44:41.1, 08:49:31.6, 10:00:23.0
55 ("Luxor", 25.6872, 32.6396, [31_216.4, 36_126.7, 36_507.1, 41_193.5]), // 08:40:16.4, 10:02:06.7, 10:08:27.1, 11:26:33.5
56]
57
58@Test(arguments: nasa2027.indices) func contactsMatchNASA2027(_ i: Int) throws {
59 let site = nasa2027[i]
60 let midnight = Date(timeIntervalSince1970: 1_817_164_800)
61 let l = try #require(LocalEclipse.at(Observer(latitude: site.lat, longitude: site.lon), near: midnight.addingTimeInterval(36_000)))
62 let c2 = try #require(l.centralBegin), c3 = try #require(l.centralEnd)
63 let ours = [l.partialBegin, c2, c3, l.partialEnd].map { $0.timeIntervalSince(midnight) }
64 for (a, b) in zip(ours, site.contacts) { #expect(abs(a - b) < 3, "\(site.name)") }
65 #expect(abs((ours[2] - ours[1]) - (site.contacts[2] - site.contacts[1])) < 3, "\(site.name) duration")
66}
67
68@Test func contactsRefinedToATenthOfASecond() throws {
69 let tarifa = Observer(latitude: 36.0143, longitude: -5.6044)
70 let l = try #require(LocalEclipse.at(tarifa, near: Date(timeIntervalSince1970: 1_817_201_220)))
71 let inner = { (s: LocalEclipse.Shadow) in abs(s.k) - s.r }, partial = { (s: LocalEclipse.Shadow) in s.p - s.r }
72 for (contact, edge) in [(l.partialBegin, partial), (try #require(l.centralBegin), inner), (try #require(l.centralEnd), inner), (l.partialEnd, partial)] {
73 let before = edge(LocalEclipse.shadow(at: contact.addingTimeInterval(-0.05), from: tarifa))
74 let after = edge(LocalEclipse.shadow(at: contact.addingTimeInterval(0.05), from: tarifa))
75 #expect(before * after < 0)
76 }
77}
78
4979@Test func moonMatchesHorizonsFrame() {
5080 // Artemis I from JPL Horizons (geocentric, J2000 ecliptic, km) at 12:47 UTC on 21 November 2022,
5181 // ten minutes before its closest approach, about 130 km above the Moon.