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

App/Sources/SkyModel.swift

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/App/Sources/SkyModel.swift history · blame · raw

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}