Commit 63e0316d09
Verified · cmc
Layout: unified · split
App/Sources/EclipseLocation.swift added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | import CoreLocation | |
| 2 | import MapKit | |
| 3 | import Observation | |
| 4 | import 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 | |
| 10 | final 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`. | |
| 57 | func 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 { | ||
| 10 | 10 | @State private var path: EclipsePath? |
| 11 | 11 | @State private var local: LocalEclipse? |
| 12 | 12 | @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" | |
| 13 | 17 | @Environment(\.dismiss) private var dismiss |
| 14 | 18 | |
| 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 | ||
| 15 | 22 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 16 | 23 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| 17 | 24 | |
| @@ -20,7 +27,7 @@ struct EclipseScreen: View { | ||
| 20 | 27 | ScrollView { |
| 21 | 28 | VStack(alignment: .leading, spacing: 16) { |
| 22 | 29 | if let path { |
| 23 | EclipseMap(path: path, observer: observer, ink: ink, accent: accent) | |
| 30 | EclipseMap(path: path, observer: site, ink: ink, accent: accent) | |
| 24 | 31 | .aspectRatio(1.6, contentMode: .fit) |
| 25 | 32 | .accessibilityLabel("Map of the path of the eclipse") |
| 26 | 33 | facts(path) |
| @@ -41,17 +48,26 @@ struct EclipseScreen: View { | ||
| 41 | 48 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } |
| 42 | 49 | } |
| 43 | 50 | .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 | |
| 46 | 53 | loaded = true |
| 47 | 54 | } |
| 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() } | |
| 48 | 64 | } |
| 49 | 65 | |
| 50 | 66 | private func facts(_ p: EclipsePath) -> some View { |
| 51 | 67 | let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) } |
| 52 | 68 | 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))"), | |
| 55 | 71 | ] |
| 56 | 72 | if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) } |
| 57 | 73 | if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) } |
| @@ -65,18 +81,33 @@ struct EclipseScreen: View { | ||
| 65 | 81 | @ViewBuilder |
| 66 | 82 | private var here: some View { |
| 67 | 83 | 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) | |
| 69 | 85 | if let l = local { |
| 70 | 86 | let central = l.kind == "Total" ? "Totality" : "Annular phase" |
| 87 | let zones = [zone, .gmt].compactMap { $0 } | |
| 71 | 88 | 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)) } } | |
| 73 | 96 | 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)) } } | |
| 75 | 99 | } |
| 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)) } } | |
| 78 | 102 | } |
| 79 | 103 | .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 | } | |
| 80 | 111 | if l.peakAltitude < 10 { |
| 81 | 112 | Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7) |
| 82 | 113 | } |
| @@ -88,7 +119,15 @@ struct EclipseScreen: View { | ||
| 88 | 119 | } |
| 89 | 120 | } |
| 90 | 121 | |
| 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 | } | |
| 92 | 131 | |
| 93 | 132 | private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" } |
| 94 | 133 | |
Packages/MoonriseKit/Sources/SkyEngine/DeltaT.swift added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | import CAstronomy | |
| 2 | import 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. | |
| 6 | enum 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 { | ||
| 141 | 141 | guard abs(peak.timeIntervalSince(date)) < 6 * 3600 else { return nil } |
| 142 | 142 | guard l.partial_begin.altitude > 0 || l.peak.altitude > 0 || l.partial_end.altitude > 0 else { return nil } |
| 143 | 143 | 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 } | |
| 144 | 145 | return LocalEclipse( |
| 145 | 146 | 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, | |
| 148 | 149 | 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), | |
| 151 | 152 | peakAltitude: l.peak.altitude |
| 152 | 153 | ) |
| 153 | 154 | } |
| 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 | } | |
| 154 | 197 | } |
Packages/MoonriseKit/Sources/SkyEngine/SkyEngine.swift +2 −1
| @@ -71,8 +71,9 @@ public enum SkyEngine { | ||
| 71 | 71 | static let lock = NSRecursiveLock() |
| 72 | 72 | |
| 73 | 73 | static func time(_ date: Date) -> astro_time_t { |
| 74 | DeltaT.installed | |
| 74 | 75 | // 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) | |
| 76 | 77 | } |
| 77 | 78 | |
| 78 | 79 | static func check(_ status: astro_status_t) throws { |
Packages/MoonriseKit/Tests/SkyEngineTests/DeltaTTests.swift added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | private 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) | ||
| 46 | 46 | #expect(LocalEclipse.at(Observer(latitude: 41.88, longitude: -87.63), near: peak) == nil) // Chicago: night |
| 47 | 47 | } |
| 48 | 48 | |
| 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. | |
| 52 | private 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 | ||
| 49 | 79 | @Test func moonMatchesHorizonsFrame() { |
| 50 | 80 | // Artemis I from JPL Horizons (geocentric, J2000 ecliptic, km) at 12:47 UTC on 21 November 2022, |
| 51 | 81 | // ten minutes before its closest approach, about 130 km above the Moon. |