Commit 5395f2f431
Verified · cmc
Layout: unified · split
App/Sources/EclipseScreen.swift added +164
| @@ -0,0 +1,164 @@ | |||
| 1 | import SkyEngine | ||
| 2 | import SkyRenderer | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// Map of a total or annular eclipse's path, with timings at greatest eclipse and for the observer. | ||
| 6 | struct EclipseScreen: View { | ||
| 7 | var date: Date | ||
| 8 | var observer: Observer | ||
| 9 | var placeName: String | ||
| 10 | @State private var path: EclipsePath? | ||
| 11 | @State private var local: LocalEclipse? | ||
| 12 | @State private var loaded = false | ||
| 13 | @Environment(\.dismiss) private var dismiss | ||
| 14 | |||
| 15 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | ||
| 16 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | ||
| 17 | |||
| 18 | var body: some View { | ||
| 19 | NavigationStack { | ||
| 20 | ScrollView { | ||
| 21 | VStack(alignment: .leading, spacing: 16) { | ||
| 22 | if let path { | ||
| 23 | EclipseMap(path: path, observer: observer, ink: ink, accent: accent) | ||
| 24 | .aspectRatio(1.6, contentMode: .fit) | ||
| 25 | .accessibilityLabel("Map of the path of the eclipse") | ||
| 26 | facts(path) | ||
| 27 | } else if loaded { | ||
| 28 | Text("This eclipse is partial everywhere, so there is no path to map.").font(.footnote).dimmed(0.7) | ||
| 29 | } else { | ||
| 30 | ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200) | ||
| 31 | } | ||
| 32 | if loaded { here } | ||
| 33 | } | ||
| 34 | .padding(20) | ||
| 35 | .readableWidth() | ||
| 36 | } | ||
| 37 | .background(Color.black.ignoresSafeArea()) | ||
| 38 | .foregroundStyle(ink) | ||
| 39 | .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse") | ||
| 40 | .navigationBarTitleDisplayMode(.inline) | ||
| 41 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } | ||
| 42 | } | ||
| 43 | .task { | ||
| 44 | let date = date, observer = observer | ||
| 45 | (path, local) = await Task.detached { (EclipsePath.compute(near: date), LocalEclipse.at(observer, near: date)) }.value | ||
| 46 | loaded = true | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | private func facts(_ p: EclipsePath) -> some View { | ||
| 51 | let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) } | ||
| 52 | 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))"), | ||
| 55 | ] | ||
| 56 | if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) } | ||
| 57 | if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) } | ||
| 58 | return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | ||
| 59 | ForEach(rows, id: \.0) { r in | ||
| 60 | GridRow { Text(r.0).dimmed(0.7); Text(r.1).monospacedDigit() }.font(.subheadline) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | @ViewBuilder | ||
| 66 | private var here: some View { | ||
| 67 | VStack(alignment: .leading, spacing: 6) { | ||
| 68 | Text("From \(placeName)").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) | ||
| 69 | if let l = local { | ||
| 70 | let central = l.kind == "Total" ? "Totality" : "Annular phase" | ||
| 71 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | ||
| 72 | GridRow { Text("Begins").dimmed(0.7); Text(time(l.partialBegin)) } | ||
| 73 | 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)))") } | ||
| 75 | } | ||
| 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)) } | ||
| 78 | } | ||
| 79 | .font(.subheadline.monospacedDigit()) | ||
| 80 | if l.peakAltitude < 10 { | ||
| 81 | Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7) | ||
| 82 | } | ||
| 83 | } else { | ||
| 84 | Text("Not visible from here.").font(.subheadline).dimmed(0.7) | ||
| 85 | } | ||
| 86 | Text("Never look at the Sun without certified eclipse glasses, except during totality itself.") | ||
| 87 | .font(.footnote).foregroundStyle(accent).padding(.top, 6) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .standard) } | ||
| 92 | |||
| 93 | private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" } | ||
| 94 | |||
| 95 | private func latLon(_ p: (lat: Double, lon: Double)) -> String { | ||
| 96 | String(format: "%.1f°%@, %.1f°%@", abs(p.lat), p.lat >= 0 ? "N" : "S", abs(p.lon), p.lon >= 0 ? "E" : "W") | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Equirectangular map around the path: coastlines, the band between the limits, and the centre line. | ||
| 101 | struct EclipseMap: View { | ||
| 102 | var path: EclipsePath | ||
| 103 | var observer: Observer | ||
| 104 | var ink: Color | ||
| 105 | var accent: Color | ||
| 106 | |||
| 107 | var body: some View { | ||
| 108 | Canvas { context, size in | ||
| 109 | let centers = path.points.map(\.center) | ||
| 110 | let lats = centers.map(\.lat), lons = centers.map(\.lon) | ||
| 111 | let latRange = (lats.min()! - 8)...(lats.max()! + 8) | ||
| 112 | let lonRange = (lons.min()! - 10)...(lons.max()! + 10) | ||
| 113 | func pt(_ lat: Double, _ lon: Double) -> CGPoint { | ||
| 114 | CGPoint(x: (lon - lonRange.lowerBound) / (lonRange.upperBound - lonRange.lowerBound) * size.width, | ||
| 115 | y: (latRange.upperBound - lat) / (latRange.upperBound - latRange.lowerBound) * size.height) | ||
| 116 | } | ||
| 117 | |||
| 118 | context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(ink.opacity(0.04))) | ||
| 119 | var grid = Path() | ||
| 120 | for lat in stride(from: -80.0, through: 80, by: 10) where latRange.contains(lat) { | ||
| 121 | grid.move(to: pt(lat, lonRange.lowerBound)); grid.addLine(to: pt(lat, lonRange.upperBound)) | ||
| 122 | } | ||
| 123 | for lon in stride(from: -180.0, through: 180, by: 10) where lonRange.contains(lon) { | ||
| 124 | grid.move(to: pt(latRange.lowerBound, lon)); grid.addLine(to: pt(latRange.upperBound, lon)) | ||
| 125 | } | ||
| 126 | context.stroke(grid, with: .color(ink.opacity(0.08)), lineWidth: 0.5) | ||
| 127 | |||
| 128 | var coast = Path() | ||
| 129 | for line in Coastline.lines { | ||
| 130 | var pen = false | ||
| 131 | for p in line { | ||
| 132 | let q = pt(p.lat, p.lon) | ||
| 133 | if pen { coast.addLine(to: q) } else { coast.move(to: q); pen = true } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | context.stroke(coast, with: .color(ink.opacity(0.55)), lineWidth: 0.8) | ||
| 137 | |||
| 138 | let limits = path.points.compactMap { p in p.north.flatMap { n in p.south.map { (n, $0) } } } | ||
| 139 | if limits.count > 1 { | ||
| 140 | var band = Path() | ||
| 141 | band.move(to: pt(limits[0].0.lat, limits[0].0.lon)) | ||
| 142 | for l in limits.dropFirst() { band.addLine(to: pt(l.0.lat, l.0.lon)) } | ||
| 143 | for l in limits.reversed() { band.addLine(to: pt(l.1.lat, l.1.lon)) } | ||
| 144 | band.closeSubpath() | ||
| 145 | context.fill(band, with: .color(accent.opacity(0.4))) | ||
| 146 | } | ||
| 147 | var center = Path() | ||
| 148 | center.addLines(centers.map { pt($0.lat, $0.lon) }) | ||
| 149 | context.stroke(center, with: .color(accent), lineWidth: 1.2) | ||
| 150 | |||
| 151 | let g = pt(path.greatest.lat, path.greatest.lon) | ||
| 152 | context.stroke(Path(ellipseIn: CGRect(x: g.x - 5, y: g.y - 5, width: 10, height: 10)), with: .color(ink), lineWidth: 1.5) | ||
| 153 | if latRange.contains(observer.latitude), lonRange.contains(observer.longitude) { | ||
| 154 | let o = pt(observer.latitude, observer.longitude) | ||
| 155 | context.fill(Path(ellipseIn: CGRect(x: o.x - 4, y: o.y - 4, width: 8, height: 8)), with: .color(ink)) | ||
| 156 | context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: o.x, y: o.y - 11)) | ||
| 157 | } | ||
| 158 | } | ||
| 159 | .overlay(alignment: .bottomLeading) { | ||
| 160 | Text("Shaded: where the eclipse is \(path.kind.lowercased()). Ring: greatest eclipse.") | ||
| 161 | .font(.caption2).dimmed(0.7).padding(6) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | } | ||
App/Sources/EventsScreen.swift +11 −1
| @@ -7,6 +7,7 @@ struct EventsScreen: View { | |||
| 7 | var placeName: String | 7 | var placeName: String |
| 8 | @State private var events: [SkyEvent] = [] | 8 | @State private var events: [SkyEvent] = [] |
| 9 | @State private var filter = "notable" | 9 | @State private var filter = "notable" |
| 10 | @State private var eclipse: SkyEvent? | ||
| 10 | @Environment(\.dismiss) private var dismiss | 11 | @Environment(\.dismiss) private var dismiss |
| 11 | 12 | ||
| 12 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | 13 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| @@ -39,7 +40,15 @@ struct EventsScreen: View { | |||
| 39 | } | 40 | } |
| 40 | ForEach(months, id: \.0) { month, items in | 41 | ForEach(months, id: \.0) { month, items in |
| 41 | Section(month) { | 42 | Section(month) { |
| 42 | ForEach(items) { row($0) } | 43 | ForEach(items) { e in |
| 44 | if e.kind == .solarEclipse { | ||
| 45 | Button { eclipse = e } label: { row(e) } | ||
| 46 | .buttonStyle(.plain) | ||
| 47 | .accessibilityHint("Shows the path and local times") | ||
| 48 | } else { | ||
| 49 | row(e) | ||
| 50 | } | ||
| 51 | } | ||
| 43 | } | 52 | } |
| 44 | } | 53 | } |
| 45 | } | 54 | } |
| @@ -49,6 +58,7 @@ struct EventsScreen: View { | |||
| 49 | .navigationTitle("Events") | 58 | .navigationTitle("Events") |
| 50 | .navigationBarTitleDisplayMode(.inline) | 59 | .navigationBarTitleDisplayMode(.inline) |
| 51 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } | 60 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } |
| 61 | .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) } | ||
| 52 | } | 62 | } |
| 53 | .foregroundStyle(ink) | 63 | .foregroundStyle(ink) |
| 54 | .task { | 64 | .task { |
PLAN.org +1
| @@ -393,6 +393,7 @@ Sky, System, Sun, Launches, Library core (above). | |||
| 393 | - Events: eclipses, meteor showers, conjunctions, oppositions, each with | 393 | - Events: eclipses, meteor showers, conjunctions, oppositions, each with |
| 394 | an observing plan. Eclipse path maps with local timings; the 2 August | 394 | an observing plan. Eclipse path maps with local timings; the 2 August |
| 395 | 2027 total eclipse (Spain, North Africa, Egypt) is a launch target. | 395 | 2027 total eclipse (Spain, North Africa, Egypt) is a launch target. |
| 396 | Path maps and local timings done for every total and annular eclipse. | ||
| 396 | - Deep Space Network Now: which DSN dishes are talking to which | 397 | - Deep Space Network Now: which DSN dishes are talking to which |
| 397 | spacecraft. | 398 | spacecraft. |
| 398 | - More satellites: Tiangong, Hubble, Starlink trains, bright passes. Done: | 399 | - More satellites: Tiangong, Hubble, Starlink trains, bright passes. Done: |
Packages/MoonriseKit/Sources/SkyEngine/EclipsePath.swift added +154
| @@ -0,0 +1,154 @@ | |||
| 1 | import CAstronomy | ||
| 2 | import Foundation | ||
| 3 | |||
| 4 | /// Ground track of the Moon's umbra (total) or antumbra (annular) for one solar eclipse, | ||
| 5 | /// from the Sun–Moon shadow axis intersected with the Earth's ellipsoid. | ||
| 6 | public struct EclipsePath: Sendable { | ||
| 7 | public struct Point: Sendable, Equatable { | ||
| 8 | public var date: Date | ||
| 9 | public var center: (lat: Double, lon: Double) | ||
| 10 | public var north: (lat: Double, lon: Double)? | ||
| 11 | public var south: (lat: Double, lon: Double)? | ||
| 12 | /// Width of the path at this point, km. | ||
| 13 | public var widthKm: Double | ||
| 14 | |||
| 15 | public static func == (a: Point, b: Point) -> Bool { a.date == b.date } | ||
| 16 | } | ||
| 17 | |||
| 18 | public var kind: String | ||
| 19 | public var peak: Date | ||
| 20 | /// Greatest eclipse. | ||
| 21 | public var greatest: (lat: Double, lon: Double) | ||
| 22 | /// Duration of totality or annularity at greatest eclipse, seconds. | ||
| 23 | public var centralDuration: TimeInterval? | ||
| 24 | public var points: [Point] | ||
| 25 | |||
| 26 | static let sunRadiusKm = 695_700.0 | ||
| 27 | static let moonRadiusKm = 1_737.4 | ||
| 28 | static let earthA = 6_378.137 | ||
| 29 | static let earthF = 1 / 298.257223563 | ||
| 30 | static let kmPerAU = 1.495978707e8 | ||
| 31 | |||
| 32 | /// The path of the first total or annular eclipse whose peak is within a day of `date`. | ||
| 33 | public static func compute(near date: Date, step: TimeInterval = 60) -> EclipsePath? { | ||
| 34 | SkyEngine.lock.lock() | ||
| 35 | defer { SkyEngine.lock.unlock() } | ||
| 36 | let g = Astronomy_SearchGlobalSolarEclipse(SkyEngine.time(date.addingTimeInterval(-86_400))) | ||
| 37 | guard g.status == ASTRO_SUCCESS, g.kind == ECLIPSE_TOTAL || g.kind == ECLIPSE_ANNULAR else { return nil } | ||
| 38 | let peak = SkyEngine.date(g.peak) | ||
| 39 | guard abs(peak.timeIntervalSince(date)) < 86_400 else { return nil } | ||
| 40 | |||
| 41 | var points: [Point] = [] | ||
| 42 | var t = peak.addingTimeInterval(-4 * 3600) | ||
| 43 | while t <= peak.addingTimeInterval(4 * 3600) { | ||
| 44 | if let p = point(at: t, step: step) { points.append(p) } | ||
| 45 | t = t.addingTimeInterval(step) | ||
| 46 | } | ||
| 47 | |||
| 48 | let at = Observer(latitude: g.latitude, longitude: g.longitude) | ||
| 49 | let local = Astronomy_SearchLocalSolarEclipse(SkyEngine.time(peak.addingTimeInterval(-6 * 3600)), at.astro) | ||
| 50 | var duration: TimeInterval? | ||
| 51 | if local.status == ASTRO_SUCCESS, local.kind == g.kind { | ||
| 52 | duration = SkyEngine.date(local.total_end.time).timeIntervalSince(SkyEngine.date(local.total_begin.time)) | ||
| 53 | } | ||
| 54 | return EclipsePath(kind: SkyEngine.eclipseKind(g.kind), peak: peak, greatest: (g.latitude, g.longitude), centralDuration: duration, points: points) | ||
| 55 | } | ||
| 56 | |||
| 57 | /// Centre and limits of the path at `date`, or nil if the shadow axis misses the Earth. | ||
| 58 | static func point(at date: Date, step: TimeInterval) -> Point? { | ||
| 59 | guard let (center, n, r) = axis(at: date), let next = axis(at: date.addingTimeInterval(step)) else { return nil } | ||
| 60 | guard let c = center.hit, let c2 = next.0.hit else { return nil } | ||
| 61 | // Unit vector across the track: perpendicular to the shadow axis and the ground motion. | ||
| 62 | let across = normalize(cross(n, c2 - c)) | ||
| 63 | let offset = across * (abs(r) / kmPerAU) | ||
| 64 | let north = Ray(origin: center.origin + offset, direction: n).hit | ||
| 65 | let south = Ray(origin: center.origin - offset, direction: n).hit | ||
| 66 | let t = SkyEngine.time(date) | ||
| 67 | func geo(_ v: SIMD3<Double>) -> (lat: Double, lon: Double) { | ||
| 68 | var vec = astro_vector_t(status: ASTRO_SUCCESS, x: v.x, y: v.y, z: v.z, t: t) | ||
| 69 | let o = Astronomy_VectorObserver(&vec, EQUATOR_OF_DATE) | ||
| 70 | return (o.latitude, o.longitude) | ||
| 71 | } | ||
| 72 | var a = geo(north ?? c), b = geo(south ?? c) | ||
| 73 | if a.lat < b.lat { swap(&a, &b) } | ||
| 74 | let width = (north != nil && south != nil) ? length(north! - south!) * kmPerAU : 2 * abs(r) | ||
| 75 | return Point(date: date, center: geo(c), north: north == nil ? nil : a, south: south == nil ? nil : b, widthKm: width) | ||
| 76 | } | ||
| 77 | |||
| 78 | struct Ray { | ||
| 79 | var origin: SIMD3<Double> | ||
| 80 | var direction: SIMD3<Double> | ||
| 81 | |||
| 82 | /// First intersection with the Earth's ellipsoid, AU, equator-of-date axes. | ||
| 83 | var hit: SIMD3<Double>? { | ||
| 84 | let k = 1 / (1 - EclipsePath.earthF) | ||
| 85 | let o = SIMD3(origin.x, origin.y, origin.z * k), d = SIMD3(direction.x, direction.y, direction.z * k) | ||
| 86 | let a = EclipsePath.earthA / EclipsePath.kmPerAU | ||
| 87 | let A = dot(d, d), B = 2 * dot(o, d), C = dot(o, o) - a * a | ||
| 88 | let disc = B * B - 4 * A * C | ||
| 89 | guard disc >= 0 else { return nil } | ||
| 90 | let s = (-B - disc.squareRoot()) / (2 * A) | ||
| 91 | guard s > 0 else { return nil } | ||
| 92 | let p = o + d * s | ||
| 93 | return SIMD3(p.x, p.y, p.z / k) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | /// Shadow axis from the Moon away from the Sun (equator of date), its direction, | ||
| 98 | /// and the umbra radius where it meets the Earth (km; negative for the antumbra). | ||
| 99 | static func axis(at date: Date) -> (Ray, SIMD3<Double>, Double)? { | ||
| 100 | var t = SkyEngine.time(date) | ||
| 101 | let rot = Astronomy_Rotation_EQJ_EQD(&t) | ||
| 102 | let sun = Astronomy_RotateVector(rot, Astronomy_GeoVector(BODY_SUN, t, NO_ABERRATION)) | ||
| 103 | let moon = Astronomy_RotateVector(rot, Astronomy_GeoMoon(t)) | ||
| 104 | let s = SIMD3(sun.x, sun.y, sun.z), m = SIMD3(moon.x, moon.y, moon.z) | ||
| 105 | let n = normalize(m - s) | ||
| 106 | let ray = Ray(origin: m, direction: n) | ||
| 107 | guard let hit = ray.hit else { return nil } | ||
| 108 | let D = length(m - s) * kmPerAU | ||
| 109 | let L = length(hit - m) * kmPerAU | ||
| 110 | let r = moonRadiusKm - L * (sunRadiusKm - moonRadiusKm) / D | ||
| 111 | return (ray, n, r) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | private func dot(_ a: SIMD3<Double>, _ b: SIMD3<Double>) -> Double { (a * b).sum() } | ||
| 116 | private func length(_ a: SIMD3<Double>) -> Double { dot(a, a).squareRoot() } | ||
| 117 | private func normalize(_ a: SIMD3<Double>) -> SIMD3<Double> { a / length(a) } | ||
| 118 | private func cross(_ a: SIMD3<Double>, _ b: SIMD3<Double>) -> SIMD3<Double> { | ||
| 119 | SIMD3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x) | ||
| 120 | } | ||
| 121 | |||
| 122 | /// A solar eclipse as seen from one place. | ||
| 123 | public struct LocalEclipse: Sendable { | ||
| 124 | public var kind: String | ||
| 125 | public var obscuration: Double | ||
| 126 | public var partialBegin: Date | ||
| 127 | public var centralBegin: Date? | ||
| 128 | public var peak: Date | ||
| 129 | public var centralEnd: Date? | ||
| 130 | public var partialEnd: Date | ||
| 131 | /// Sun's altitude at the peak, degrees. | ||
| 132 | public var peakAltitude: Double | ||
| 133 | |||
| 134 | /// The observer's view of the solar eclipse peaking within six hours of `date`, if any part is above the horizon. | ||
| 135 | public static func at(_ observer: Observer, near date: Date) -> LocalEclipse? { | ||
| 136 | SkyEngine.lock.lock() | ||
| 137 | defer { SkyEngine.lock.unlock() } | ||
| 138 | let l = Astronomy_SearchLocalSolarEclipse(SkyEngine.time(date.addingTimeInterval(-6 * 3600)), observer.astro) | ||
| 139 | guard l.status == ASTRO_SUCCESS else { return nil } | ||
| 140 | let peak = SkyEngine.date(l.peak.time) | ||
| 141 | guard abs(peak.timeIntervalSince(date)) < 6 * 3600 else { return nil } | ||
| 142 | guard l.partial_begin.altitude > 0 || l.peak.altitude > 0 || l.partial_end.altitude > 0 else { return nil } | ||
| 143 | let central = l.kind == ECLIPSE_TOTAL || l.kind == ECLIPSE_ANNULAR | ||
| 144 | return LocalEclipse( | ||
| 145 | 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, | ||
| 148 | peak: peak, | ||
| 149 | centralEnd: central ? SkyEngine.date(l.total_end.time) : nil, | ||
| 150 | partialEnd: SkyEngine.date(l.partial_end.time), | ||
| 151 | peakAltitude: l.peak.altitude | ||
| 152 | ) | ||
| 153 | } | ||
| 154 | } | ||
Packages/MoonriseKit/Tests/SkyEngineTests/EclipsePathTests.swift added +47
| @@ -0,0 +1,47 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import SkyEngine | ||
| 4 | |||
| 5 | /// Distance in km between two points on a sphere. | ||
| 6 | private func km(_ a: (lat: Double, lon: Double), _ b: (lat: Double, lon: Double)) -> Double { | ||
| 7 | let r = Double.pi / 180 | ||
| 8 | let h = pow(sin((b.lat - a.lat) * r / 2), 2) + cos(a.lat * r) * cos(b.lat * r) * pow(sin((b.lon - a.lon) * r / 2), 2) | ||
| 9 | return 2 * 6371 * asin(h.squareRoot()) | ||
| 10 | } | ||
| 11 | |||
| 12 | @Test func totalEclipse2027Path() throws { | ||
| 13 | // NASA (Espenak): greatest eclipse 2027-08-02 10:07 UT at 25.5° N, 33.2° E, | ||
| 14 | // totality 6 m 23 s, path 258 km wide; the path crosses southern Spain and Luxor. | ||
| 15 | let path = try #require(EclipsePath.compute(near: Date(timeIntervalSince1970: 1_817_201_220))) | ||
| 16 | #expect(path.kind == "Total") | ||
| 17 | #expect(abs(path.greatest.lat - 25.5) < 0.3 && abs(path.greatest.lon - 33.2) < 0.3) | ||
| 18 | let duration = try #require(path.centralDuration) | ||
| 19 | #expect(abs(duration - 383) < 5) | ||
| 20 | #expect(path.points.count > 100) | ||
| 21 | |||
| 22 | func nearest(_ place: (lat: Double, lon: Double)) -> Double { | ||
| 23 | path.points.map { km($0.center, place) }.min()! | ||
| 24 | } | ||
| 25 | #expect(nearest((25.69, 32.64)) < 40) // Luxor, close to the centre line | ||
| 26 | #expect(nearest((36.01, -5.60)) < 120) // Tarifa, inside the path | ||
| 27 | |||
| 28 | let middle = path.points.min { abs($0.date.timeIntervalSince(path.peak)) < abs($1.date.timeIntervalSince(path.peak)) }! | ||
| 29 | #expect(abs(middle.widthKm - 258) < 20) | ||
| 30 | } | ||
| 31 | |||
| 32 | @Test func noPathForPartialEclipse() { | ||
| 33 | // 21 September 2025 was partial only. | ||
| 34 | #expect(EclipsePath.compute(near: Date(timeIntervalSince1970: 1_758_483_660)) == nil) | ||
| 35 | } | ||
| 36 | |||
| 37 | @Test func localCircumstances2027() throws { | ||
| 38 | let peak = Date(timeIntervalSince1970: 1_817_201_220) | ||
| 39 | let luxor = try #require(LocalEclipse.at(Observer(latitude: 25.69, longitude: 32.64), near: peak)) | ||
| 40 | #expect(luxor.kind == "Total") | ||
| 41 | let begin = try #require(luxor.centralBegin), end = try #require(luxor.centralEnd) | ||
| 42 | #expect(end.timeIntervalSince(begin) > 360) | ||
| 43 | let madrid = try #require(LocalEclipse.at(Observer(latitude: 40.42, longitude: -3.70), near: peak)) | ||
| 44 | #expect(madrid.kind == "Partial" && madrid.centralBegin == nil) | ||
| 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 | ||
| 47 | } | ||