Packages/MoonriseKit/Sources/SkyEngine/Events.swift
152 lines · 8121 bytes
5 symbols in this file
1import CAstronomy
2import Foundation
3
4public struct SkyEvent: Sendable, Identifiable, Equatable {
5 public enum Kind: String, Sendable {
6 case moonPhase, lunarEclipse, solarEclipse, opposition, elongation, season, meteorShower
7 }
8
9 public var kind: Kind
10 public var date: Date
11 public var title: String
12 public var detail: String
13 /// Whether it can be seen from the observer's location; nil when that does not apply.
14 public var visibleHere: Bool?
15 public var id: String { "\(kind.rawValue)-\(title)-\(Int(date.timeIntervalSince1970))" }
16}
17
18/// A major annual meteor shower (International Meteor Organization working list).
19public struct MeteorShower: Sendable {
20 public var name: String
21 /// Typical peak, month and day; the exact time varies by a day or so each year.
22 public var month: Int
23 public var day: Int
24 /// Zenithal hourly rate at peak under ideal skies.
25 public var zhr: Int
26
27 public static let major: [MeteorShower] = [
28 MeteorShower(name: "Quadrantids", month: 1, day: 3, zhr: 80),
29 MeteorShower(name: "Lyrids", month: 4, day: 22, zhr: 18),
30 MeteorShower(name: "Eta Aquariids", month: 5, day: 6, zhr: 50),
31 MeteorShower(name: "Perseids", month: 8, day: 12, zhr: 100),
32 MeteorShower(name: "Orionids", month: 10, day: 21, zhr: 20),
33 MeteorShower(name: "Leonids", month: 11, day: 17, zhr: 15),
34 MeteorShower(name: "Geminids", month: 12, day: 14, zhr: 150),
35 MeteorShower(name: "Ursids", month: 12, day: 22, zhr: 10),
36 ]
37}
38
39extension SkyEngine {
40 static func eclipseKind(_ k: astro_eclipse_kind_t) -> String {
41 switch k {
42 case ECLIPSE_PENUMBRAL: "Penumbral"
43 case ECLIPSE_PARTIAL: "Partial"
44 case ECLIPSE_ANNULAR: "Annular"
45 case ECLIPSE_TOTAL: "Total"
46 default: ""
47 }
48 }
49
50 /// Astronomical events from `start` for `days` days, sorted by date.
51 public static func events(from start: Date, days: Double = 366, observer: Observer) -> [SkyEvent] {
52 lock.lock()
53 defer { lock.unlock() }
54 let end = start.addingTimeInterval(days * 86_400)
55 var out: [SkyEvent] = []
56
57 // Full and new moons
58 var t = time(start)
59 while true {
60 let q = Astronomy_SearchMoonQuarter(t)
61 guard q.status == ASTRO_SUCCESS else { break }
62 let d = date(q.time)
63 if d > end { break }
64 if q.quarter == 0 || q.quarter == 2 {
65 out.append(SkyEvent(kind: .moonPhase, date: d, title: q.quarter == 0 ? "New moon" : "Full moon", detail: q.quarter == 0 ? "Darkest skies of the month." : "The Moon is opposite the Sun and up all night.", visibleHere: nil))
66 }
67 t = Astronomy_AddDays(q.time, 1)
68 }
69
70 // Lunar eclipses
71 var lunar = Astronomy_SearchLunarEclipse(time(start))
72 while lunar.status == ASTRO_SUCCESS, date(lunar.peak) <= end {
73 var peak = lunar.peak
74 let moon = Astronomy_Equator(BODY_MOON, &peak, observer.astro, EQUATOR_OF_DATE, ABERRATION)
75 let alt = Astronomy_Horizon(&peak, observer.astro, moon.ra, moon.dec, REFRACTION_NORMAL).altitude
76 let minutes = lunar.sd_total > 0 ? lunar.sd_total * 2 : lunar.sd_partial * 2
77 let length = minutes > 0 ? String(format: " %@ phase lasts %.0f minutes.", lunar.sd_total > 0 ? "Total" : "Partial", minutes) : ""
78 out.append(SkyEvent(kind: .lunarEclipse, date: date(lunar.peak), title: "\(eclipseKind(lunar.kind)) lunar eclipse", detail: (alt > 0 ? "The Moon is up here at mid-eclipse." : "The Moon is below the horizon here at mid-eclipse.") + length, visibleHere: alt > 0))
79 lunar = Astronomy_NextLunarEclipse(lunar.peak)
80 }
81
82 // Solar eclipses: global, with local circumstances for the observer
83 var local: [Date: astro_local_solar_eclipse_t] = [:]
84 var l = Astronomy_SearchLocalSolarEclipse(time(start), observer.astro)
85 while l.status == ASTRO_SUCCESS, date(l.peak.time) <= end {
86 local[date(l.peak.time)] = l
87 l = Astronomy_NextLocalSolarEclipse(l.peak.time, observer.astro)
88 }
89 var solar = Astronomy_SearchGlobalSolarEclipse(time(start))
90 while solar.status == ASTRO_SUCCESS, date(solar.peak) <= end {
91 let peakDate = date(solar.peak)
92 let here = local.first { abs($0.key.timeIntervalSince(peakDate)) < 6 * 3600 }?.value
93 var detail: String
94 var visible = false
95 if let here, here.peak.altitude > 0 {
96 visible = true
97 detail = String(format: "From here: %@, %.0f%% of the Sun covered at %@.", eclipseKind(here.kind).lowercased(), here.obscuration * 100, date(here.peak.time).formatted(date: .omitted, time: .shortened))
98 } else {
99 detail = "Not visible from here."
100 }
101 if solar.kind == ECLIPSE_TOTAL || solar.kind == ECLIPSE_ANNULAR {
102 detail += String(format: " Greatest eclipse at %.1f°%@, %.1f°%@.", abs(solar.latitude), solar.latitude >= 0 ? "N" : "S", abs(solar.longitude), solar.longitude >= 0 ? "E" : "W")
103 }
104 out.append(SkyEvent(kind: .solarEclipse, date: peakDate, title: "\(eclipseKind(solar.kind)) solar eclipse", detail: detail, visibleHere: visible))
105 solar = Astronomy_NextGlobalSolarEclipse(solar.peak)
106 }
107
108 // Oppositions of the outer planets
109 for body in [Body.mars, .jupiter, .saturn, .uranus, .neptune] {
110 let r = Astronomy_SearchRelativeLongitude(body.astro, 0, time(start))
111 guard r.status == ASTRO_SUCCESS, date(r.time) <= end else { continue }
112 out.append(SkyEvent(kind: .opposition, date: date(r.time), title: "\(body.rawValue.capitalized) at opposition", detail: "Opposite the Sun: up all night and at its closest and brightest for the year.", visibleHere: nil))
113 }
114
115 // Greatest elongations of Mercury and Venus
116 for body in [Body.mercury, .venus] {
117 var from = time(start)
118 while true {
119 let e = Astronomy_SearchMaxElongation(body.astro, from)
120 guard e.status == ASTRO_SUCCESS, date(e.time) <= end else { break }
121 let when = e.visibility == VISIBLE_MORNING ? "morning" : "evening"
122 out.append(SkyEvent(kind: .elongation, date: date(e.time), title: "\(body.rawValue.capitalized) at greatest \(when) elongation", detail: String(format: "%.0f° from the Sun, best placed in the %@ sky.", e.elongation, when), visibleHere: nil))
123 from = Astronomy_AddDays(e.time, 10)
124 }
125 }
126
127 // Equinoxes and solstices
128 let cal = Calendar(identifier: .gregorian)
129 let y0 = cal.component(.year, from: start)
130 for y in y0...(y0 + 1) {
131 let s = Astronomy_Seasons(Int32(y))
132 guard s.status == ASTRO_SUCCESS else { continue }
133 for (t, name) in [(s.mar_equinox, "March equinox"), (s.jun_solstice, "June solstice"), (s.sep_equinox, "September equinox"), (s.dec_solstice, "December solstice")] {
134 let d = date(t)
135 if d >= start && d <= end { out.append(SkyEvent(kind: .season, date: d, title: name, detail: "", visibleHere: nil)) }
136 }
137 }
138
139 // Meteor showers, with how much moonlight there is at peak
140 var utc = Calendar(identifier: .gregorian)
141 utc.timeZone = TimeZone(identifier: "UTC")!
142 for y in y0...(y0 + 1) {
143 for m in MeteorShower.major {
144 guard let d = utc.date(from: DateComponents(year: y, month: m.month, day: m.day, hour: 12)), d >= start, d <= end else { continue }
145 let lit = Astronomy_Illumination(BODY_MOON, time(d)).phase_fraction
146 out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit.", m.zhr, lit * 100), visibleHere: nil))
147 }
148 }
149
150 return out.sorted { $0.date < $1.date }
151 }
152}