Packages/MoonriseKit/Sources/SkyEngine/EclipseTimeline.swift
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}