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/Feeds/SunSummary.swift

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/Packages/MoonriseKit/Sources/Feeds/SunSummary.swift history · blame · raw

84 lines · 4493 bytes

5 symbols in this file
 1import Foundation
 2import SkyEngine
 3
 4/// One or two plain sentences on what the Sun is doing to Earth now, from NOAA's feeds.
 5/// The first matching rule wins: a geomagnetic storm, then a radiation storm, then a flare, then quiet.
 6/// Every sentence names a figure from a feed and its time.
 7public enum SunSummary {
 8    /// `nil` when none of the feeds has loaded. `night` is the current or next night at the observer.
 9    public static func text(
10        scales: NOAAScales?, kp: [KpBar], probabilities: SolarProbabilities?, xray: [XRaySample],
11        geomagneticLatitude: Double, night: DateInterval, at now: Date,
12        timeZone: TimeZone = .current, locale: Locale = .current
13    ) -> String? {
14        guard scales != nil || !kp.isEmpty || !xray.isEmpty else { return nil }
15        var style = Date.FormatStyle(date: .omitted, time: .shortened, locale: locale)
16        style.timeZone = timeZone
17        let time = { (d: Date) in d.formatted(style) }
18        let current = scales?.current ?? .init(r: 0, s: 0, g: 0)
19        let asOf = scales.map { " as of \(time($0.asOf))" } ?? ""
20
21        // Storm-level bars (Kp 5 or more, G1) from now to 24 hours ahead.
22        let bar: TimeInterval = 3 * 3600
23        let storms = kp.filter { $0.kp >= 5 && $0.start.addingTimeInterval(bar) > now && $0.start < now.addingTimeInterval(86_400) }
24        let visible = storms.filter {
25            Geomagnetic.visibleFrom(kp: $0.kp) <= abs(geomagneticLatitude)
26                && overlaps($0.start, bar, night)
27        }
28        let sky = geomagneticLatitude >= 0 ? "northern" : "southern"
29
30        // 1. Geomagnetic storm.
31        if let b = visible.max(by: { $0.kp < $1.kp }), let g = SpaceWeather.gScale(b.kp) {
32            let end = time(b.start.addingTimeInterval(bar))
33            let when = b.start > now ? "forecast for \(time(b.start))–\(end)" : night.contains(now) ? "now" : "forecast until \(end)"
34            return "\(name(g)) storm (G\(g)) \(when), Kp \(String(format: "%.1f", b.kp)). Aurora possible low in the \(sky) sky."
35        }
36        if current.g >= 1 {
37            let needed = Geomagnetic.kpNeeded(geomagneticLatitude: geomagneticLatitude)
38            var text = "\(name(current.g)) storm (G\(current.g))\(asOf)."
39            if needed > 9 {
40                text += " Aurora at your latitude would need more than Kp 9."
41            } else if needed > 5 {
42                text += " Aurora at your latitude needs Kp \(String(format: "%.1f", needed))."
43            } else if kp.contains(where: { overlaps($0.start, bar, night) }) {
44                text += " Kp is forecast below 5 after dark."
45            }
46            return text
47        }
48
49        // 2. Solar radiation storm.
50        if current.s >= 1 {
51            return "\(name(current.s)) radiation storm (S\(current.s))\(asOf). It affects polar flights and satellites, not people on the ground."
52        }
53
54        // 3. Flare of class M or above in the last 6 hours, or a strong radio blackout now.
55        if let peak = xray.filter({ $0.time > now.addingTimeInterval(-6 * 3600) }).max(by: { $0.flux < $1.flux }), peak.flux >= 1e-5 {
56            return "\(name(rScale(peak.flux))) flare (\(XRay.flareClass(peak.flux))) at \(time(peak.time)). Brief radio blackout on Earth's day side."
57        }
58        if current.r >= 3 {
59            return "\(name(current.r)) radio blackout (R\(current.r))\(asOf). It affects radio on Earth's day side."
60        }
61
62        // 4. Quiet.
63        if let next = storms.min(by: { $0.start < $1.start }), let g = SpaceWeather.gScale(next.kp) {
64            return "Quiet now. \(name(g)) storm (G\(g)) forecast from \(time(next.start)), Kp \(String(format: "%.1f", next.kp))."
65        }
66        if let m = probabilities?.day(at: now)?.m {
67            return "Quiet. \(m)% chance of an M-class flare today."
68        }
69        return "Quiet. No storms, and no flare of class M or above in the last 6 hours."
70    }
71
72    private static func overlaps(_ start: Date, _ duration: TimeInterval, _ interval: DateInterval) -> Bool {
73        (DateInterval(start: start, duration: duration).intersection(with: interval)?.duration ?? 0) > 0
74    }
75
76    private static func name(_ level: Int) -> String {
77        NOAAScales.levelName(level).capitalized
78    }
79
80    /// NOAA R scale for a flare's peak flux: R1 at M1, R2 at M5, R3 at X1, R4 at X10, R5 at X20.
81    private static func rScale(_ flux: Double) -> Int {
82        [1e-5, 5e-5, 1e-4, 1e-3, 2e-3].lastIndex { flux >= $0 }.map { $0 + 1 } ?? 0
83    }
84}