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/EclipseTimeline.swift

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca
moonrise/Packages/MoonriseKit/Sources/SkyEngine/EclipseTimeline.swift history · blame · raw

122 lines · 6558 bytes

  1import Foundation
  2
  3/// One site's contacts, the Sun's position at each, and the spoken cue script for eclipse day.
  4public struct EclipseTimeline: Sendable {
  5    /// The glasses-off cue needs at least this much computed totality…
  6    public static let minimumTotality: TimeInterval = 30
  7    /// …and a location fix better than this, metres.
  8    public static let maximumAccuracy = 100.0
  9
 10    public struct Contact: Sendable {
 11        public var name: String
 12        public var date: Date
 13        public var sun: Horizontal
 14    }
 15
 16    public enum Haptic: Sendable {
 17        case tap, double, long
 18    }
 19
 20    public struct Cue: Sendable, Equatable {
 21        public var date: Date
 22        public var text: String
 23        public var haptic: Haptic
 24        /// Shown in red.
 25        public var warning: Bool
 26    }
 27
 28    /// Why there is no glasses-off cue.
 29    public enum Withheld: Sendable, Equatable {
 30        /// No totality here.
 31        case partial
 32        /// Under `minimumTotality`.
 33        case short
 34        /// No fix, or one worse than `maximumAccuracy`.
 35        case imprecise
 36        /// The Sun is below the horizon at second contact.
 37        case sunDown
 38    }
 39
 40    public var eclipse: LocalEclipse
 41    public var observer: Observer
 42    /// C1, C2, Max, C3, C4; C2 and C3 only for a total or annular eclipse.
 43    public var contacts: [Contact]
 44    public var cues: [Cue]
 45    public var glassesOffWithheld: Withheld?
 46
 47    /// `accuracy`: horizontal accuracy of the location fix in metres, or nil for a chosen place.
 48    public init(_ eclipse: LocalEclipse, observer: Observer, accuracy: Double?) throws {
 49        self.eclipse = eclipse
 50        self.observer = observer
 51        var named = [("C1", eclipse.partialBegin)]
 52        if let c2 = eclipse.centralBegin { named.append(("C2", c2)) }
 53        named.append(("Max", eclipse.peak))
 54        if let c3 = eclipse.centralEnd { named.append(("C3", c3)) }
 55        named.append(("C4", eclipse.partialEnd))
 56        contacts = try named.map { Contact(name: $0.0, date: $0.1, sun: try SkyEngine.horizontal(.sun, at: $0.1, from: observer, refraction: true)) }
 57
 58        if eclipse.kind != "Total" {
 59            glassesOffWithheld = .partial
 60        } else if let b = eclipse.centralBegin, let e = eclipse.centralEnd, e.timeIntervalSince(b) < Self.minimumTotality {
 61            glassesOffWithheld = .short
 62        } else if accuracy.map({ $0 >= Self.maximumAccuracy }) ?? true {
 63            glassesOffWithheld = .imprecise
 64        } else if contacts[1].sun.altitude <= 0 {
 65            glassesOffWithheld = .sunDown
 66        }
 67        cues = Self.script(eclipse, withheld: glassesOffWithheld).sorted { $0.date < $1.date }
 68    }
 69
 70    static func script(_ e: LocalEclipse, withheld: Withheld?) -> [Cue] {
 71        func cue(_ date: Date, _ text: String, _ haptic: Haptic = .tap, warning: Bool = false) -> Cue {
 72            Cue(date: date, text: text, haptic: haptic, warning: warning)
 73        }
 74        var cues: [Cue] = []
 75        guard e.kind == "Total", let c2 = e.centralBegin, let c3 = e.centralEnd else {
 76            let annular = e.kind == "Annular"
 77            cues.append(cue(e.partialBegin, "First contact. This eclipse is \(annular ? "annular" : "partial") here, so keep your glasses on the whole time.", warning: true))
 78            if annular, let c2 = e.centralBegin, let c3 = e.centralEnd {
 79                cues.append(cue(c2, "The ring of the Sun is complete. Glasses stay on.", .long))
 80                cues.append(cue(c3, "The ring is breaking. Glasses stay on."))
 81            } else {
 82                cues.append(cue(e.peak.addingTimeInterval(-60), "One minute to maximum eclipse."))
 83                cues.append(cue(e.peak, "Maximum eclipse, \(Int((e.obscuration * 100).rounded())) percent of the Sun covered. Glasses stay on.", .long))
 84            }
 85            cues.append(cue(e.partialEnd, "Last contact. The eclipse is over."))
 86            return cues
 87        }
 88        cues.append(cue(e.partialBegin, "First contact. The Moon is starting to cover the Sun. Keep your glasses on whenever you look at it."))
 89        if c2.timeIntervalSince(e.partialBegin) > 360 { cues.append(cue(c2.addingTimeInterval(-300), "Five minutes to totality.")) }
 90        cues.append(cue(c2.addingTimeInterval(-120), "Two minutes. Lay out a white sheet and watch it for shadow bands."))
 91        cues.append(cue(c2.addingTimeInterval(-60), "One minute to totality. Glasses stay on.", .double))
 92        cues.append(cue(c2.addingTimeInterval(-15), "Watch for Baily's beads and the diamond ring, through your glasses.", .double))
 93        for n in 1...5 { cues.append(cue(c2.addingTimeInterval(-Double(n)), "\(n)")) }
 94        switch withheld {
 95        case nil:
 96            cues.append(cue(c2, "Totality. Wait until the last bead of sunlight has gone, then glasses off.", .long))
 97        case .short:
 98            cues.append(cue(c2, "Totality here is under \(Int(Self.minimumTotality)) seconds. Keep your glasses on.", .long, warning: true))
 99        case .imprecise:
100            cues.append(cue(c2, "Totality is due, but your position is not precise enough to call it. Keep your glasses on.", .long, warning: true))
101        case .sunDown, .partial:
102            cues.append(cue(c2, "Keep your glasses on.", .long, warning: true))
103        }
104        let duration = c3.timeIntervalSince(c2)
105        if duration >= 60 { cues.append(cue(c3.addingTimeInterval(-30), "Thirty seconds of totality left.")) }
106        cues.append(cue(c3.addingTimeInterval(-10), "Glasses on.", .long, warning: true))
107        cues.append(cue(c3, "Third contact. Beads and the diamond ring on the other side, through your glasses.", .double))
108        cues.append(cue(c3.addingTimeInterval(30), "Watch the white sheet for shadow bands again."))
109        cues.append(cue(e.partialEnd, "Last contact. The eclipse is over."))
110        return cues
111    }
112
113    /// Where rehearsal mode sets its clock: three minutes before totality, or before maximum elsewhere.
114    public var rehearsalStart: Date { (eclipse.kind == "Total" ? eclipse.centralBegin ?? eclipse.peak : eclipse.peak).addingTimeInterval(-180) }
115
116    /// The Sun's position from first to last contact, every `step` seconds, ending at last contact.
117    public func sunPath(every step: TimeInterval) throws -> [(date: Date, sun: Horizontal)] {
118        var dates = Array(stride(from: eclipse.partialBegin, to: eclipse.partialEnd, by: step))
119        dates.append(eclipse.partialEnd)
120        return try dates.map { ($0, try SkyEngine.horizontal(.sun, at: $0, from: observer, refraction: true)) }
121    }
122}