| @@ -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 | } |