App/Sources/SkyModel.swift
210 lines · 9691 bytes
18 symbols in this file
1import Answers
2import CoreLocation
3import Feeds
4import Foundation
5import Library
6import Observation
7import SkyEngine
8import SkyRenderer
9
10/// Serializes Astronomy Engine calls, which are not thread-safe.
11actor Astronomer {
12 func scene(at date: Date, from observer: Observer) throws -> SkyScene {
13 try SkyScene(date: date, observer: observer)
14 }
15
16 func details(of body: Body, at date: Date, from observer: Observer) throws -> BodyDetails {
17 let h = try SkyEngine.horizontal(body, at: date, from: observer, refraction: true)
18 let eq = try SkyEngine.equatorial(body, at: date, from: observer)
19 let i = try SkyEngine.illumination(body, at: date)
20 let rs = SkyEngine.riseSet(body, after: date, from: observer)
21 return BodyDetails(body: body, position: h, distanceAU: eq.distance, magnitude: i.magnitude, phaseFraction: i.phaseFraction, rise: rs.rise, set: rs.set)
22 }
23
24 func tonight(at date: Date, from observer: Observer) throws -> Tonight {
25 try Tonight(at: date, from: observer)
26 }
27}
28
29struct BodyDetails: Sendable {
30 var body: Body
31 var position: Horizontal
32 var distanceAU: Double
33 var magnitude: Double
34 var phaseFraction: Double
35 var rise: Date?
36 var set: Date?
37}
38
39/// Current place, the displayed time and the scene computed for them.
40@MainActor
41@Observable
42final class SkyModel: NSObject, CLLocationManagerDelegate {
43 /// Used until a location fix arrives or when location is denied.
44 static let fallback = Observer(latitude: 41.8781, longitude: -87.6298)
45
46 private(set) var observer = SkyModel.fallback
47 private(set) var hasLocation = false
48 /// Name shown under the title: "Your location", a chosen city, or the fallback.
49 private(set) var placeName = "Chicago (location unavailable)"
50 /// Sun's altitude at the displayed time, for automatic night mode.
51 private(set) var sunAltitude = 0.0
52 private(set) var scene: SkyScene?
53 private(set) var lastBuild: Duration = .zero
54 private(set) var tonight: Tonight?
55 private(set) var spaceWeather: SpaceWeather?
56 private(set) var passes: [SatellitePass] = []
57 private var elements: [OrbitalElements] = []
58 private var elementsFetched: Date?
59 private var passesComputed: (at: Date, observer: Observer, elementsFetched: Date?)?
60 private var weatherFetched: Date?
61 /// Hours from now shown by the time scrubber.
62 var offsetHours = 0.0 { didSet { refresh() } }
63 /// Year shown instead of this one (astronomical numbering: 0 is 1 BCE), or nil for now.
64 var historicalYear: Int? { didSet { refresh(); poleStar = historicalYear.flatMap { _ in SkyModel.poleStar(at: displayedDate, south: observer.latitude < 0) } } }
65 /// Brightest-nearest star to the visible celestial pole in the historical year, and its distance from the pole.
66 private(set) var poleStar: (name: String, degrees: Double)?
67 var displayedDate: Date {
68 let d = Date.now.addingTimeInterval(offsetHours * 3600)
69 guard let y = historicalYear else { return d }
70 return SkyModel.date(d, inYear: y)
71 }
72
73 /// The same month, day and time in year `y` (Julian calendar before 1582, as Foundation's Gregorian calendar does).
74 static func date(_ d: Date, inYear y: Int) -> Date {
75 let calendar = Calendar(identifier: .gregorian)
76 var c = calendar.dateComponents([.month, .day, .hour, .minute, .second], from: d)
77 c.era = y > 0 ? 1 : 0
78 c.year = y > 0 ? y : 1 - y
79 return calendar.date(from: c) ?? d
80 }
81
82 /// The star brighter than magnitude 3.7 (so Thuban, 3.65, counts) closest to the north (or south) celestial pole at `date`.
83 static func poleStar(at date: Date, south: Bool) -> (name: String, degrees: Double)? {
84 let best = Catalog.stars.filter { $0.magnitude < 3.7 }.map { star -> (Star, Double) in
85 let dec = SkyEngine.equatorOfDate(j2000: star.position(at: date), at: date).dec
86 return (star, south ? 90 + dec : 90 - dec)
87 }.min { $0.1 < $1.1 }
88 guard let (star, d) = best else { return nil }
89 return (Library.page(hr: star.hr)?.title ?? formerPoleStars[star.hr] ?? "HR \(star.hr)", d)
90 }
91
92 /// Stars near the north celestial pole between 3000 BCE and 3000 CE that have no Library page.
93 static let formerPoleStars = [5291: "Thuban", 5563: "Kochab", 5735: "Pherkad", 8974: "Errai", 8162: "Alderamin"]
94
95 let astronomer = Astronomer()
96 private let manager = CLLocationManager()
97 private var building = false
98 private var pending = false
99
100 override init() {
101 super.init()
102 manager.delegate = self
103 manager.desiredAccuracy = kCLLocationAccuracyKilometer
104 }
105
106 func start() {
107 applyLocationSettings()
108 }
109
110 /// Uses the chosen city, or asks Core Location for the current position.
111 func applyLocationSettings() {
112 let d = UserDefaults.standard
113 if d.string(forKey: SettingsKey.locationMode) == "city" {
114 let lat = d.object(forKey: SettingsKey.cityLatitude) as? Double ?? Self.fallback.latitude
115 let lon = d.object(forKey: SettingsKey.cityLongitude) as? Double ?? Self.fallback.longitude
116 observer = Observer(latitude: lat, longitude: lon)
117 placeName = d.string(forKey: SettingsKey.cityName) ?? "Chicago"
118 hasLocation = true
119 Place(latitude: lat, longitude: lon, name: placeName, kind: .city).save(to: d)
120 } else {
121 if !hasLocation { placeName = "Chicago (location unavailable)" }
122 manager.requestWhenInUseAuthorization()
123 manager.requestLocation()
124 }
125 refresh()
126 }
127
128 /// Rebuilds the scene for the displayed time. Requests made during a build
129 /// collapse into one rebuild when it finishes.
130 func refresh() {
131 AuroraAlerts.shared.update(observer: observer)
132 guard !building else { pending = true; return }
133 building = true
134 let date = displayedDate, observer = observer, astronomer = astronomer
135 Task {
136 let clock = ContinuousClock()
137 var scene: SkyScene?
138 let start = clock.now
139 scene = try? await astronomer.scene(at: date, from: observer)
140 lastBuild = clock.now - start
141 if let scene { self.scene = scene }
142 if offsetHours == 0, historicalYear == nil { tonight = try? await astronomer.tonight(at: date, from: observer) }
143 sunAltitude = (try? SkyEngine.horizontal(.sun, at: date, from: observer, refraction: true).altitude) ?? 0
144 building = false
145 if pending { pending = false; refresh() }
146 }
147 }
148
149 private struct SatelliteList: Decodable { var satellites: [OrbitalElements] }
150
151 /// Orbital elements from the backend (refreshed hourly), then passes for the next three days.
152 func refreshPasses() async {
153 if elementsFetched.map({ Date.now.timeIntervalSince($0) > 3600 }) ?? true, let apiBase {
154 if let (data, _) = try? await URLSession.shared.data(from: apiBase.appending(path: "v1/satellites")),
155 let list = try? JSONDecoder().decode(SatelliteList.self, from: data), !list.satellites.isEmpty {
156 elements = list.satellites
157 elementsFetched = .now
158 }
159 }
160 // Reuse the last result for an hour unless the place or the orbits changed (about 1 s of work for 158 objects).
161 if let last = passesComputed, last.observer == observer, last.elementsFetched == elementsFetched,
162 Date.now.timeIntervalSince(last.at) < 3600 {
163 passes.removeAll { $0.set < .now }
164 return
165 }
166 let elements = elements, observer = observer
167 passes = await Task.detached {
168 elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise }
169 }.value
170 passesComputed = (.now, observer, elementsFetched)
171 }
172
173 /// SWPC asks for no more than once a minute; this checks every 10.
174 func refreshSpaceWeather() async {
175 if let weatherFetched, Date.now.timeIntervalSince(weatherFetched) < 600 { return }
176 do {
177 spaceWeather = try await SpaceWeather.fetch()
178 weatherFetched = .now
179 } catch {
180 log.error("Space weather failed: \(error.localizedDescription, privacy: .public)")
181 }
182 }
183
184 func details(of body: Body) async -> BodyDetails? {
185 try? await astronomer.details(of: body, at: displayedDate, from: observer)
186 }
187
188 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
189 guard let c = locations.last?.coordinate else { return }
190 Task { @MainActor in
191 guard UserDefaults.standard.string(forKey: SettingsKey.locationMode) != "city" else { return }
192 self.observer = Observer(latitude: c.latitude, longitude: c.longitude)
193 self.hasLocation = true
194 self.placeName = "Your location"
195 Place(latitude: c.latitude, longitude: c.longitude, name: self.placeName, kind: .located).save(to: .standard)
196 self.refresh()
197 }
198 }
199
200 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
201 Task { @MainActor in log.error("Location failed: \(error.localizedDescription, privacy: .public)") }
202 }
203
204 nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
205 let status = manager.authorizationStatus
206 Task { @MainActor in
207 if [.authorizedWhenInUse, .authorizedAlways].contains(status) { self.manager.requestLocation() }
208 }
209 }
210}