Packages/MoonriseKit/Sources/Feeds/SunSummary.swift
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}