krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Packages/MoonriseKit/Sources/SkyEngine/Events.swift

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/Packages/MoonriseKit/Sources/SkyEngine/Events.swift history · blame · raw

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}