| @@ -0,0 +1,152 @@ |
| |
1 | import CAstronomy |
| |
2 | import Foundation |
| |
3 | |
| |
4 | public 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). |
| |
19 | public 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 | |
| |
39 | extension 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 | } |