Commit 7fc9a61cd3
Verified · cmc
Layout: unified · split
App/Sources/SolarSystem3D.swift +9 −21
| @@ -140,26 +140,13 @@ struct SolarWeather: Equatable { | |||
| 140 | var kp: Double? | 140 | var kp: Double? |
| 141 | /// Strongest X-ray flux in the last three hours and when it peaked. | 141 | /// Strongest X-ray flux in the last three hours and when it peaked. |
| 142 | var flare: (flux: Double, time: Date)? | 142 | var flare: (flux: Double, time: Date)? |
| 143 | /// From `SunFeeds.summary`, the same text as the Sun tab. | ||
| 144 | var summary: String? | ||
| 143 | 145 | ||
| 144 | static func == (a: SolarWeather, b: SolarWeather) -> Bool { a.kp == b.kp && a.flare?.flux == b.flare?.flux } | 146 | static func == (a: SolarWeather, b: SolarWeather) -> Bool { a.kp == b.kp && a.flare?.flux == b.flare?.flux && a.summary == b.summary } |
| 145 | 147 | ||
| 146 | var showsFlare: Bool { (flare?.flux ?? 0) >= 1e-5 } | 148 | var showsFlare: Bool { (flare?.flux ?? 0) >= 1e-5 } |
| 147 | var showsAurora: Bool { (kp ?? 0) >= 4 } | 149 | var showsAurora: Bool { (kp ?? 0) >= 4 } |
| 148 | |||
| 149 | var summary: String { | ||
| 150 | var parts: [String] = [] | ||
| 151 | if let f = flare { | ||
| 152 | parts.append(showsFlare | ||
| 153 | ? "\(XRay.flareClass(f.flux)) solar flare at \(f.time.formatted(date: .omitted, time: .shortened))" | ||
| 154 | : "No large flares in the last 3 h (peak \(XRay.flareClass(f.flux)))") | ||
| 155 | } | ||
| 156 | if let kp { | ||
| 157 | parts.append(showsAurora | ||
| 158 | ? String(format: "Kp %.1f: aurora ring around Earth's poles", kp) | ||
| 159 | : String(format: "Kp %.1f: geomagnetic field quiet", kp)) | ||
| 160 | } | ||
| 161 | return parts.isEmpty ? "Loading space weather" : parts.joined(separator: " · ") | ||
| 162 | } | ||
| 163 | } | 150 | } |
| 164 | 151 | ||
| 165 | /// Which optional layers are drawn. | 152 | /// Which optional layers are drawn. |
| @@ -285,7 +272,7 @@ struct SolarSystem3DView: View { | |||
| 285 | let when = abs(d) < 1 ? "peak today" : d > 0 ? "peak in \(Int(d.rounded())) days" : "peaked \(Int((-d).rounded())) days ago" | 272 | let when = abs(d) < 1 ? "peak today" : d > 0 ? "peak in \(Int(d.rounded())) days" : "peaked \(Int((-d).rounded())) days ago" |
| 286 | lines.append("\(s.name) \(when): Earth is crossing debris from \(parent.name).") | 273 | lines.append("\(s.name) \(when): Earth is crossing debris from \(parent.name).") |
| 287 | } | 274 | } |
| 288 | lines.append(live ? weather.summary : "Live space weather is shown only for today.") | 275 | lines.append(live ? weather.summary ?? "Loading space weather" : "Live space weather is shown only for today.") |
| 289 | return lines.joined(separator: "\n") | 276 | return lines.joined(separator: "\n") |
| 290 | } | 277 | } |
| 291 | 278 | ||
| @@ -926,12 +913,13 @@ struct SolarSystem3DView: View { | |||
| 926 | private func loadWeather() async { | 913 | private func loadWeather() async { |
| 927 | while !Task.isCancelled { | 914 | while !Task.isCancelled { |
| 928 | async let kp = try? SpaceWeather.fetch() | 915 | async let kp = try? SpaceWeather.fetch() |
| 929 | async let xray = try? XRay.fetch() | 916 | async let sun = SunFeeds.load(force: false) |
| 930 | let (k, x) = await (kp, xray) | 917 | let (k, s) = await (kp, sun) |
| 931 | var w = SolarWeather(kp: k?.kpNow) | 918 | var w = SolarWeather(kp: k?.kpNow) |
| 932 | if let samples = x { | 919 | if let s { |
| 933 | let recent = samples.filter { $0.time > .now.addingTimeInterval(-3 * 3600) } | 920 | let recent = s.snapshot.xray.filter { $0.time > .now.addingTimeInterval(-3 * 3600) } |
| 934 | if let peak = recent.max(by: { $0.flux < $1.flux }) { w.flare = (peak.flux, peak.time) } | 921 | if let peak = recent.max(by: { $0.flux < $1.flux }) { w.flare = (peak.flux, peak.time) } |
| 922 | w.summary = SunFeeds.summary(s.snapshot).map { s.complete ? $0 : "\($0) (\(SunFeeds.source(s.snapshot)))" } | ||
| 935 | } | 923 | } |
| 936 | #if DEBUG | 924 | #if DEBUG |
| 937 | if UserDefaults.standard.bool(forKey: "debugSolarEvents") { w.kp = 6.3; w.flare = (2.4e-5, .now) } | 925 | if UserDefaults.standard.bool(forKey: "debugSolarEvents") { w.kp = 6.3; w.flare = (2.4e-5, .now) } |
App/Sources/SunScreen.swift +207 −31
| @@ -1,16 +1,22 @@ | |||
| 1 | import Charts | 1 | import Charts |
| 2 | import CoreLocation | 2 | import CoreLocation |
| 3 | import Feeds | 3 | import Feeds |
| 4 | import SkyEngine | ||
| 4 | import SkyRenderer | 5 | import SkyRenderer |
| 5 | import SwiftUI | 6 | import SwiftUI |
| 6 | 7 | ||
| 7 | struct SunScreen: View { | 8 | struct SunScreen: View { |
| 8 | @State private var wavelength = SolarImage.corona | 9 | @State private var wavelength = SolarImage.corona |
| 9 | @State private var kp: [KpBar] = [] | 10 | @State private var data: SunSnapshot? |
| 10 | @State private var xray: [XRaySample] = [] | 11 | @State private var summary: String? |
| 11 | @State private var ovation: Ovation? | ||
| 12 | @State private var failed = false | 12 | @State private var failed = false |
| 13 | @State private var imageID = Date.now | 13 | @State private var imageID = Date.now |
| 14 | @State private var picture: Picture? | ||
| 15 | @State private var imageFailed = false | ||
| 16 | |||
| 17 | private var kp: [KpBar] { data?.kp ?? [] } | ||
| 18 | private var xray: [XRaySample] { data?.xray ?? [] } | ||
| 19 | private var ovation: Ovation? { data?.ovation } | ||
| 14 | 20 | ||
| 15 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | 21 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 16 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | 22 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| @@ -21,10 +27,21 @@ struct SunScreen: View { | |||
| 21 | VStack(alignment: .leading, spacing: 0) { | 27 | VStack(alignment: .leading, spacing: 0) { |
| 22 | Text("Sun").font(.title.weight(.medium)) | 28 | Text("Sun").font(.title.weight(.medium)) |
| 23 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) | 29 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 24 | Text("The Sun as NASA's Solar Dynamics Observatory sees it now, and the space weather it sends our way: flares, magnetic storms and the aurora.") | 30 | if let summary, let data { |
| 25 | .font(.subheadline).dimmed(0.75).padding(.bottom, 16) | 31 | Text(summary) |
| 32 | Text(SunFeeds.source(data)).font(.caption2).dimmed(0.5).padding(.top, 4) | ||
| 33 | } else if !failed { | ||
| 34 | Text("Loading").font(.footnote).dimmed(0.6) | ||
| 35 | } | ||
| 36 | if failed { | ||
| 37 | Text(data == nil ? "Couldn't load space weather. Pull to retry." : "Couldn't reach NOAA. Showing the last saved data. Pull to retry.") | ||
| 38 | .font(.footnote).foregroundStyle(accent).padding(.top, 8) | ||
| 39 | } | ||
| 40 | if let scales = data?.scales { | ||
| 41 | section("NOAA scales", note: "Radio blackouts (R), solar radiation storms (S) and geomagnetic storms (G), each from 0 (none) to 5 (extreme).") { scalesStrip(scales) } | ||
| 42 | } | ||
| 26 | 43 | ||
| 27 | image | 44 | image.padding(.top, 24) |
| 28 | Picker("Wavelength", selection: $wavelength) { | 45 | Picker("Wavelength", selection: $wavelength) { |
| 29 | ForEach(SolarImage.allCases) { Text($0.name).tag($0) } | 46 | ForEach(SolarImage.allCases) { Text($0.name).tag($0) } |
| 30 | } | 47 | } |
| @@ -34,12 +51,14 @@ struct SunScreen: View { | |||
| 34 | Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4) | 51 | Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4) |
| 35 | 52 | ||
| 36 | section("Aurora tonight", note: "NOAA's forecast of where the aurora may be overhead in the next hour; brighter green is more likely. From a few hundred kilometres further from the pole it can still be seen low on the horizon.") { auroraMap } | 53 | section("Aurora tonight", note: "NOAA's forecast of where the aurora may be overhead in the next hour; brighter green is more likely. From a few hundred kilometres further from the pole it can still be seen low on the horizon.") { auroraMap } |
| 37 | section("Solar flares, last 24 h", note: "X-rays from the Sun, measured by NOAA's GOES satellites. Spikes are flares: M and X class can disrupt radio and GPS, and the biggest can bring a storm a day or two later.") { flares } | 54 | section("Solar flares, last 24 h", note: "X-rays from the Sun, measured by NOAA's GOES satellites. Spikes are flares: M and X class can disrupt radio and GPS, and the biggest can bring a storm a day or two later.") { |
| 55 | flares | ||
| 56 | if let p = data?.probabilities { probabilityTable(p) } | ||
| 57 | } | ||
| 38 | section("Geomagnetic activity (Kp)", note: "How disturbed Earth's magnetic field is, from 0 (quiet) to 9. At 5 or more it is a storm and the aurora reaches lower latitudes.") { kpChart } | 58 | section("Geomagnetic activity (Kp)", note: "How disturbed Earth's magnetic field is, from 0 (quiet) to 9. At 5 or more it is a storm and the aurora reaches lower latitudes.") { kpChart } |
| 39 | 59 | ||
| 40 | if failed { | 60 | Text("The Sun as NASA's Solar Dynamics Observatory sees it now, and the space weather it sends our way: flares, magnetic storms and the aurora.") |
| 41 | Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12) | 61 | .font(.footnote).dimmed(0.6).padding(.top, 24) |
| 42 | } | ||
| 43 | Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).dimmed(0.5).padding(.vertical, 16) | 62 | Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).dimmed(0.5).padding(.vertical, 16) |
| 44 | } | 63 | } |
| 45 | .padding(.horizontal, 20) | 64 | .padding(.horizontal, 20) |
| @@ -49,37 +68,128 @@ struct SunScreen: View { | |||
| 49 | .background(background.ignoresSafeArea()) | 68 | .background(background.ignoresSafeArea()) |
| 50 | .refreshable { await load(force: true) } | 69 | .refreshable { await load(force: true) } |
| 51 | .task { await load(force: false) } | 70 | .task { await load(force: false) } |
| 71 | .task(id: "\(wavelength.rawValue) \(imageID)") { await loadImage() } | ||
| 52 | } | 72 | } |
| 53 | 73 | ||
| 54 | /// NOAA updates these every minute or so; a visit within 5 minutes of the last load reuses it. | ||
| 55 | private func load(force: Bool) async { | 74 | private func load(force: Bool) async { |
| 56 | if !force, let cached: ([KpBar], [XRaySample], Ovation?) = FeedMemo.get("sun", maxAge: 300) { | 75 | if data == nil { show(await SunFeeds.lastSaved()) } |
| 57 | (kp, xray, ovation) = cached | 76 | if force { imageID = .now } |
| 77 | let result = await SunFeeds.load(force: force) | ||
| 78 | show(result?.snapshot) | ||
| 79 | failed = !(result?.complete ?? false) | ||
| 80 | } | ||
| 81 | |||
| 82 | private func show(_ snapshot: SunSnapshot?) { | ||
| 83 | guard let snapshot else { return } | ||
| 84 | data = snapshot | ||
| 85 | summary = SunFeeds.summary(snapshot) | ||
| 86 | } | ||
| 87 | |||
| 88 | /// An SDO image, with the time it was saved when it comes from the cache. | ||
| 89 | private struct Picture { | ||
| 90 | var wavelength: SolarImage | ||
| 91 | var image: UIImage | ||
| 92 | var loaded: Date | ||
| 93 | var saved: Date? | ||
| 94 | } | ||
| 95 | |||
| 96 | /// SDO sends no-store, so the last image of each wavelength is saved to Caches for use offline. | ||
| 97 | /// A fresh image of the same wavelength under 5 minutes old is kept. | ||
| 98 | private func loadImage() async { | ||
| 99 | let w = wavelength | ||
| 100 | if let p = picture, p.wavelength == w, p.saved == nil, p.loaded > imageID, Date.now.timeIntervalSince(p.loaded) < 300 { return } | ||
| 101 | let file = URL.cachesDirectory.appending(path: "SDO/\(w.rawValue).jpg") | ||
| 102 | picture = nil | ||
| 103 | imageFailed = false | ||
| 104 | if let (data, response) = try? await URLSession.shared.data(from: w.url(size: 1024)), | ||
| 105 | (response as? HTTPURLResponse)?.statusCode == 200, let image = UIImage(data: data) { | ||
| 106 | picture = Picture(wavelength: w, image: image, loaded: .now) | ||
| 107 | try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 108 | try? data.write(to: file, options: .atomic) | ||
| 58 | return | 109 | return |
| 59 | } | 110 | } |
| 60 | imageID = .now | 111 | guard !Task.isCancelled else { return } |
| 61 | async let k = try? SpaceWeather.fetchKpBars() | 112 | if let image = UIImage(contentsOfFile: file.path(percentEncoded: false)) { |
| 62 | async let x = try? XRay.fetch() | 113 | let saved = (try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate |
| 63 | async let o = try? Ovation.fetch() | 114 | picture = Picture(wavelength: w, image: image, loaded: .now, saved: saved ?? .distantPast) |
| 64 | let (kpResult, xrayResult, ovationResult) = await (k, x, o) | 115 | } else { |
| 65 | kp = kpResult ?? kp | 116 | imageFailed = true |
| 66 | xray = xrayResult ?? xray | 117 | } |
| 67 | ovation = ovationResult ?? ovation | ||
| 68 | failed = kpResult == nil || xrayResult == nil || ovationResult == nil | ||
| 69 | if !failed { FeedMemo.set("sun", (kp, xray, ovation)) } | ||
| 70 | } | 118 | } |
| 71 | 119 | ||
| 72 | private var image: some View { | 120 | private var image: some View { |
| 73 | AsyncImage(url: wavelength.url(size: 1024)) { phase in | 121 | VStack(alignment: .leading, spacing: 4) { |
| 74 | switch phase { | 122 | Group { |
| 75 | case .success(let img): img.resizable().scaledToFit() | 123 | if let picture { |
| 76 | case .failure: Color.black.overlay(Text("Image unavailable").font(.footnote).dimmed(0.6)) | 124 | Image(uiImage: picture.image).resizable().scaledToFit() |
| 77 | default: Color.black.overlay(ProgressView().tint(ink)) | 125 | } else if imageFailed { |
| 126 | Color.black.overlay(Text("Image unavailable").font(.footnote).dimmed(0.6)) | ||
| 127 | } else { | ||
| 128 | Color.black.overlay(ProgressView().tint(ink)) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | .aspectRatio(1, contentMode: .fit) | ||
| 132 | .accessibilityLabel("The Sun at \(wavelength.name)") | ||
| 133 | if let saved = picture?.saved { | ||
| 134 | Text("Couldn't load a new image. This one was saved \(saved.formatted(.relative(presentation: .named))).") | ||
| 135 | .font(.caption2).foregroundStyle(accent) | ||
| 78 | } | 136 | } |
| 79 | } | 137 | } |
| 80 | .id(imageID) | 138 | } |
| 81 | .aspectRatio(1, contentMode: .fit) | 139 | |
| 82 | .accessibilityLabel("The Sun at \(wavelength.name)") | 140 | private func scalesStrip(_ s: NOAAScales) -> some View { |
| 141 | HStack(alignment: .top, spacing: 12) { | ||
| 142 | scale("R", "Radio", s.current.r, s.past24h.r) | ||
| 143 | Rectangle().fill(ink.opacity(0.25)).frame(width: 0.5) | ||
| 144 | scale("S", "Radiation", s.current.s, s.past24h.s) | ||
| 145 | Rectangle().fill(ink.opacity(0.25)).frame(width: 0.5) | ||
| 146 | scale("G", "Geomagnetic", s.current.g, s.past24h.g) | ||
| 147 | } | ||
| 148 | .fixedSize(horizontal: false, vertical: true) | ||
| 149 | } | ||
| 150 | |||
| 151 | private func scale(_ letter: String, _ title: String, _ level: Int, _ past: Int) -> some View { | ||
| 152 | VStack(alignment: .leading, spacing: 2) { | ||
| 153 | Text(title).font(.caption2).dimmed(0.6) | ||
| 154 | Text("\(letter)\(level)").font(.title.weight(.medium).monospacedDigit()) | ||
| 155 | .foregroundStyle(level >= 1 ? accent : ink) | ||
| 156 | Text(NOAAScales.levelName(level)).font(.footnote) | ||
| 157 | Text("24 h max \(letter)\(past)").font(.caption2).dimmed(0.6) | ||
| 158 | } | ||
| 159 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 160 | .accessibilityElement(children: .combine) | ||
| 161 | } | ||
| 162 | |||
| 163 | /// Flare and proton chances by UT day, under the X-ray chart. | ||
| 164 | private func probabilityTable(_ p: SolarProbabilities) -> some View { | ||
| 165 | let utc = TimeZone(identifier: "UTC")! | ||
| 166 | var weekday = Date.FormatStyle().weekday(.abbreviated) | ||
| 167 | weekday.timeZone = utc | ||
| 168 | let issued = Date.VerbatimFormatStyle( | ||
| 169 | format: "\(day: .defaultDigits) \(month: .abbreviated) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)", | ||
| 170 | locale: .current, timeZone: utc, calendar: Calendar(identifier: .gregorian) | ||
| 171 | ) | ||
| 172 | let days = p.days.indices.map { p.issued.addingTimeInterval(Double($0) * 86_400).formatted(weekday) } | ||
| 173 | let rows: [(String, KeyPath<SolarProbabilities.Day, Int>)] = [("C-class flare", \.c), ("M-class flare", \.m), ("X-class flare", \.x), ("Proton event", \.proton)] | ||
| 174 | return VStack(alignment: .leading, spacing: 6) { | ||
| 175 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | ||
| 176 | GridRow { | ||
| 177 | Text("Chance") | ||
| 178 | ForEach(days.indices, id: \.self) { Text(days[$0]).gridColumnAlignment(.trailing) } | ||
| 179 | } | ||
| 180 | .font(.caption2).dimmed(0.6) | ||
| 181 | ForEach(rows, id: \.0) { row in | ||
| 182 | GridRow { | ||
| 183 | Text(row.0).dimmed(0.7) | ||
| 184 | ForEach(p.days.indices, id: \.self) { Text("\(p.days[$0][keyPath: row.1])%").monospacedDigit() } | ||
| 185 | } | ||
| 186 | .font(.subheadline) | ||
| 187 | } | ||
| 188 | } | ||
| 189 | Text("NOAA SWPC forecast dated \(p.issued.formatted(issued)) UT, by UT day. A proton event is a solar radiation storm of S1 or more.") | ||
| 190 | .font(.caption2).dimmed(0.6) | ||
| 191 | } | ||
| 192 | .padding(.top, 8) | ||
| 83 | } | 193 | } |
| 84 | 194 | ||
| 85 | private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View { | 195 | private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View { |
| @@ -190,3 +300,69 @@ struct SunScreen: View { | |||
| 190 | } | 300 | } |
| 191 | } | 301 | } |
| 192 | } | 302 | } |
| 303 | |||
| 304 | /// NOAA feeds for the Sun tab and the 3D view's caption. The last complete set is saved to Application Support | ||
| 305 | /// and stands in for any feed that fails to load. | ||
| 306 | @MainActor | ||
| 307 | enum SunFeeds { | ||
| 308 | private static let file = URL.applicationSupportDirectory.appending(path: "SunSnapshot.json") | ||
| 309 | private static var saved: SunSnapshot? | ||
| 310 | |||
| 311 | /// NOAA updates these every minute or so; a load within 5 minutes of the last one reuses it. | ||
| 312 | /// `complete` is false when any feed came from the saved set, whose `fetched` date the result then carries. | ||
| 313 | /// `nil` only when nothing loaded and nothing was saved. | ||
| 314 | static func load(force: Bool) async -> (snapshot: SunSnapshot, complete: Bool)? { | ||
| 315 | if !force, let hit: SunSnapshot = FeedMemo.get("sun", maxAge: 300) { return (hit, true) } | ||
| 316 | async let k = try? SpaceWeather.fetchKpBars() | ||
| 317 | async let x = try? XRay.fetch() | ||
| 318 | async let o = try? Ovation.fetch() | ||
| 319 | async let s = try? NOAAScales.fetch() | ||
| 320 | async let p = try? SolarProbabilities.fetch() | ||
| 321 | let (kp, xray, ovation, scales, probabilities) = await (k, x, o, s, p) | ||
| 322 | if let kp, let xray, let ovation, let scales, let probabilities { | ||
| 323 | let fresh = SunSnapshot(fetched: .now, kp: kp, xray: xray, ovation: ovation, scales: scales, probabilities: probabilities) | ||
| 324 | FeedMemo.set("sun", fresh) | ||
| 325 | saved = fresh | ||
| 326 | let file = file | ||
| 327 | Task.detached(priority: .utility) { try? fresh.save(to: file) } | ||
| 328 | return (fresh, true) | ||
| 329 | } | ||
| 330 | let old = await lastSaved() | ||
| 331 | guard kp != nil || xray != nil || ovation != nil || scales != nil || probabilities != nil || old != nil else { return nil } | ||
| 332 | let merged = SunSnapshot( | ||
| 333 | fetched: old?.fetched ?? .now, | ||
| 334 | kp: kp ?? old?.kp ?? [], | ||
| 335 | xray: xray ?? old?.xray ?? [], | ||
| 336 | ovation: ovation ?? old?.ovation, | ||
| 337 | scales: scales ?? old?.scales, | ||
| 338 | probabilities: probabilities ?? old?.probabilities | ||
| 339 | ) | ||
| 340 | return (merged, false) | ||
| 341 | } | ||
| 342 | |||
| 343 | /// The last complete set, read from disk on first use. | ||
| 344 | static func lastSaved() async -> SunSnapshot? { | ||
| 345 | if saved == nil { | ||
| 346 | let file = file | ||
| 347 | saved = await Task.detached { SunSnapshot.load(from: file) }.value | ||
| 348 | } | ||
| 349 | return saved | ||
| 350 | } | ||
| 351 | |||
| 352 | /// Summary for the last known location, or Chicago. | ||
| 353 | static func summary(_ s: SunSnapshot, at date: Date = .now) -> String? { | ||
| 354 | let observer = CLLocationManager().location.map { Observer(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) } ?? SkyModel.fallback | ||
| 355 | let night = (try? Tonight(at: date, from: observer)).map { DateInterval(start: $0.start, end: $0.end) } ?? DateInterval(start: date, duration: 0) | ||
| 356 | return SunSummary.text( | ||
| 357 | scales: s.scales, kp: s.kp, probabilities: s.probabilities, xray: s.xray, | ||
| 358 | geomagneticLatitude: Geomagnetic.latitude(observer), night: night, at: date | ||
| 359 | ) | ||
| 360 | } | ||
| 361 | |||
| 362 | /// Source and age: "NOAA SWPC, 21:13", or "NOAA SWPC data from 3 hours ago" once it is over 15 minutes old. | ||
| 363 | static func source(_ s: SunSnapshot) -> String { | ||
| 364 | Date.now.timeIntervalSince(s.fetched) < 900 | ||
| 365 | ? "NOAA SWPC, \(s.fetched.formatted(date: .omitted, time: .shortened))" | ||
| 366 | : "NOAA SWPC data from \(s.fetched.formatted(.relative(presentation: .named)))" | ||
| 367 | } | ||
| 368 | } | ||
Packages/MoonriseKit/Sources/Feeds/NOAAScales.swift added +137
| @@ -0,0 +1,137 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// NOAA space weather scales (US government data): R for radio blackouts, S for solar radiation storms, | ||
| 4 | /// G for geomagnetic storms, each from 0 (none) to 5 (extreme). | ||
| 5 | public struct NOAAScales: Sendable, Equatable, Codable { | ||
| 6 | public struct Levels: Sendable, Equatable, Codable { | ||
| 7 | public var r: Int | ||
| 8 | public var s: Int | ||
| 9 | public var g: Int | ||
| 10 | |||
| 11 | public init(r: Int, s: Int, g: Int) { | ||
| 12 | self.r = r | ||
| 13 | self.s = s | ||
| 14 | self.g = g | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | /// When NOAA stamped the current levels. | ||
| 19 | public var asOf: Date | ||
| 20 | public var current: Levels | ||
| 21 | /// Highest levels in the 24 hours before `asOf`. | ||
| 22 | public var past24h: Levels | ||
| 23 | |||
| 24 | public init(asOf: Date, current: Levels, past24h: Levels) { | ||
| 25 | self.asOf = asOf | ||
| 26 | self.current = current | ||
| 27 | self.past24h = past24h | ||
| 28 | } | ||
| 29 | |||
| 30 | public static let url = URL(string: "https://services.swpc.noaa.gov/products/noaa-scales.json")! | ||
| 31 | |||
| 32 | struct Entry: Decodable { | ||
| 33 | struct Scale: Decodable { var Scale: String? } | ||
| 34 | var DateStamp: String | ||
| 35 | var TimeStamp: String | ||
| 36 | var R: Scale | ||
| 37 | var S: Scale | ||
| 38 | var G: Scale | ||
| 39 | |||
| 40 | var levels: Levels { | ||
| 41 | Levels(r: R.Scale.flatMap(Int.init) ?? 0, s: S.Scale.flatMap(Int.init) ?? 0, g: G.Scale.flatMap(Int.init) ?? 0) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Key "0" is now and "-1" the past 24 hours; "1" to "3" are forecasts, which the Sun tab takes from | ||
| 46 | /// the Kp forecast and solar_probabilities.json instead. | ||
| 47 | public static func decode(_ data: Data) throws -> NOAAScales { | ||
| 48 | let entries = try JSONDecoder().decode([String: Entry].self, from: data) | ||
| 49 | guard let now = entries["0"], let past = entries["-1"], | ||
| 50 | let asOf = try? Date("\(now.DateStamp)T\(now.TimeStamp)Z", strategy: .iso8601) else { | ||
| 51 | throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "missing current scales")) | ||
| 52 | } | ||
| 53 | return NOAAScales(asOf: asOf, current: now.levels, past24h: past.levels) | ||
| 54 | } | ||
| 55 | |||
| 56 | public static func fetch(session: URLSession = .shared) async throws -> NOAAScales { | ||
| 57 | try decode(await session.data(from: url).0) | ||
| 58 | } | ||
| 59 | |||
| 60 | /// NOAA's word for a scale level. | ||
| 61 | public static func levelName(_ level: Int) -> String { | ||
| 62 | ["none", "minor", "moderate", "strong", "severe", "extreme"][max(0, min(level, 5))] | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | /// NOAA SWPC's daily flare and proton event probabilities for the next three UT days. | ||
| 67 | public struct SolarProbabilities: Sendable, Equatable, Codable { | ||
| 68 | /// Percent chances for one UT day. | ||
| 69 | public struct Day: Sendable, Equatable, Codable { | ||
| 70 | public var c: Int | ||
| 71 | public var m: Int | ||
| 72 | public var x: Int | ||
| 73 | /// Proton event: ≥10 MeV flux at or above 10 pfu, the S1 threshold. | ||
| 74 | public var proton: Int | ||
| 75 | |||
| 76 | public init(c: Int, m: Int, x: Int, proton: Int) { | ||
| 77 | self.c = c | ||
| 78 | self.m = m | ||
| 79 | self.x = x | ||
| 80 | self.proton = proton | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | /// 00 UT on the first forecast day, as the feed dates it. | ||
| 85 | public var issued: Date | ||
| 86 | /// The first forecast day first. | ||
| 87 | public var days: [Day] | ||
| 88 | |||
| 89 | public init(issued: Date, days: [Day]) { | ||
| 90 | self.issued = issued | ||
| 91 | self.days = days | ||
| 92 | } | ||
| 93 | |||
| 94 | public static let url = URL(string: "https://services.swpc.noaa.gov/json/solar_probabilities.json")! | ||
| 95 | |||
| 96 | struct Row: Decodable { | ||
| 97 | var date: String | ||
| 98 | var c1, c2, c3, m1, m2, m3, x1, x2, x3, p1, p2, p3: Int? | ||
| 99 | |||
| 100 | enum CodingKeys: String, CodingKey { | ||
| 101 | case date | ||
| 102 | case c1 = "c_class_1_day", c2 = "c_class_2_day", c3 = "c_class_3_day" | ||
| 103 | case m1 = "m_class_1_day", m2 = "m_class_2_day", m3 = "m_class_3_day" | ||
| 104 | case x1 = "x_class_1_day", x2 = "x_class_2_day", x3 = "x_class_3_day" | ||
| 105 | case p1 = "10mev_protons_1_day", p2 = "10mev_protons_2_day", p3 = "10mev_protons_3_day" | ||
| 106 | } | ||
| 107 | |||
| 108 | var days: [Day]? { | ||
| 109 | guard let c1, let c2, let c3, let m1, let m2, let m3, let x1, let x2, let x3, let p1, let p2, let p3 else { return nil } | ||
| 110 | return [Day(c: c1, m: m1, x: x1, proton: p1), Day(c: c2, m: m2, x: x2, proton: p2), Day(c: c3, m: m3, x: x3, proton: p3)] | ||
| 111 | } | ||
| 112 | } | ||
| 113 | |||
| 114 | /// The newest complete row of the 30 the feed holds. | ||
| 115 | public static func decode(_ data: Data) throws -> SolarProbabilities { | ||
| 116 | let rows = try JSONDecoder().decode([Row].self, from: data).compactMap { row -> SolarProbabilities? in | ||
| 117 | guard let issued = SpaceWeather.parse(row.date), let days = row.days else { return nil } | ||
| 118 | return SolarProbabilities(issued: issued, days: days) | ||
| 119 | } | ||
| 120 | guard let newest = rows.max(by: { $0.issued < $1.issued }) else { | ||
| 121 | throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "no complete probability rows")) | ||
| 122 | } | ||
| 123 | return newest | ||
| 124 | } | ||
| 125 | |||
| 126 | public static func fetch(session: URLSession = .shared) async throws -> SolarProbabilities { | ||
| 127 | try decode(await session.data(from: url).0) | ||
| 128 | } | ||
| 129 | |||
| 130 | /// The forecast for the UT day containing `date`, if it is one of the three. | ||
| 131 | public func day(at date: Date) -> Day? { | ||
| 132 | let elapsed = date.timeIntervalSince(issued) | ||
| 133 | guard elapsed >= 0 else { return nil } | ||
| 134 | let i = Int(elapsed / 86_400) | ||
| 135 | return i < days.count ? days[i] : nil | ||
| 136 | } | ||
| 137 | } | ||
Packages/MoonriseKit/Sources/Feeds/Sun.swift +4 −4
| @@ -37,7 +37,7 @@ public enum SolarImage: String, CaseIterable, Sendable, Identifiable { | |||
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | /// Three-hour planetary Kp, observed and predicted (SWPC 3-day forecast product). | 39 | /// Three-hour planetary Kp, observed and predicted (SWPC 3-day forecast product). |
| 40 | public struct KpBar: Sendable, Equatable, Identifiable { | 40 | public struct KpBar: Sendable, Equatable, Identifiable, Codable { |
| 41 | public var start: Date | 41 | public var start: Date |
| 42 | public var kp: Double | 42 | public var kp: Double |
| 43 | public var predicted: Bool | 43 | public var predicted: Bool |
| @@ -57,7 +57,7 @@ extension SpaceWeather { | |||
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | /// GOES long-channel (0.1–0.8 nm) X-ray flux, which defines flare classes. | 59 | /// GOES long-channel (0.1–0.8 nm) X-ray flux, which defines flare classes. |
| 60 | public struct XRaySample: Sendable, Equatable, Identifiable { | 60 | public struct XRaySample: Sendable, Equatable, Identifiable, Codable { |
| 61 | public var time: Date | 61 | public var time: Date |
| 62 | /// W/m². | 62 | /// W/m². |
| 63 | public var flux: Double | 63 | public var flux: Double |
| @@ -96,8 +96,8 @@ public enum XRay { | |||
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | /// NOAA SWPC OVATION aurora model: probability of visible aurora (0–100) on a 1° grid. | 98 | /// NOAA SWPC OVATION aurora model: probability of visible aurora (0–100) on a 1° grid. |
| 99 | public struct Ovation: Sendable { | 99 | public struct Ovation: Sendable, Equatable, Codable { |
| 100 | public struct Cell: Sendable, Equatable { | 100 | public struct Cell: Sendable, Equatable, Codable { |
| 101 | public var lon: Double | 101 | public var lon: Double |
| 102 | public var lat: Double | 102 | public var lat: Double |
| 103 | public var probability: Double | 103 | public var probability: Double |
Packages/MoonriseKit/Sources/Feeds/SunSnapshot.swift added +29
| @@ -0,0 +1,29 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// The NOAA feeds behind the Sun tab, saved after each complete load so the tab has something to show offline. | ||
| 4 | public struct SunSnapshot: Sendable, Equatable, Codable { | ||
| 5 | public var fetched: Date | ||
| 6 | public var kp: [KpBar] | ||
| 7 | public var xray: [XRaySample] | ||
| 8 | public var ovation: Ovation? | ||
| 9 | public var scales: NOAAScales? | ||
| 10 | public var probabilities: SolarProbabilities? | ||
| 11 | |||
| 12 | public init(fetched: Date, kp: [KpBar], xray: [XRaySample], ovation: Ovation?, scales: NOAAScales?, probabilities: SolarProbabilities?) { | ||
| 13 | self.fetched = fetched | ||
| 14 | self.kp = kp | ||
| 15 | self.xray = xray | ||
| 16 | self.ovation = ovation | ||
| 17 | self.scales = scales | ||
| 18 | self.probabilities = probabilities | ||
| 19 | } | ||
| 20 | |||
| 21 | public func save(to file: URL) throws { | ||
| 22 | try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| 23 | try JSONEncoder().encode(self).write(to: file, options: .atomic) | ||
| 24 | } | ||
| 25 | |||
| 26 | public static func load(from file: URL) -> SunSnapshot? { | ||
| 27 | (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(SunSnapshot.self, from: $0) } | ||
| 28 | } | ||
| 29 | } | ||
Packages/MoonriseKit/Sources/Feeds/SunSummary.swift added +94
| @@ -0,0 +1,94 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One or two plain sentences on what the Sun is doing to Earth now, from NOAA's feeds. | ||
| 4 | /// The first matching rule wins: a geomagnetic storm, then a radiation storm, then a flare, then quiet. | ||
| 5 | /// Every sentence names a figure from a feed and its time. | ||
| 6 | public enum SunSummary { | ||
| 7 | /// Geomagnetic latitude of the auroral oval's equatorward edge for a Kp value. | ||
| 8 | /// Must match `ovalEdge` and `visibleFrom` in server/src/aurora.ts. | ||
| 9 | static func visibleFrom(kp: Double) -> Double { | ||
| 10 | 66.5 - 2.04 * kp - 6 | ||
| 11 | } | ||
| 12 | |||
| 13 | /// Lowest Kp at which aurora may be seen low on the horizon from this geomagnetic latitude. | ||
| 14 | static func kpNeeded(at geomagneticLatitude: Double) -> Double { | ||
| 15 | (66.5 - 6 - abs(geomagneticLatitude)) / 2.04 | ||
| 16 | } | ||
| 17 | |||
| 18 | /// `nil` when none of the feeds has loaded. `night` is the current or next night at the observer. | ||
| 19 | public static func text( | ||
| 20 | scales: NOAAScales?, kp: [KpBar], probabilities: SolarProbabilities?, xray: [XRaySample], | ||
| 21 | geomagneticLatitude: Double, night: DateInterval, at now: Date, | ||
| 22 | timeZone: TimeZone = .current, locale: Locale = .current | ||
| 23 | ) -> String? { | ||
| 24 | guard scales != nil || !kp.isEmpty || !xray.isEmpty else { return nil } | ||
| 25 | var style = Date.FormatStyle(date: .omitted, time: .shortened, locale: locale) | ||
| 26 | style.timeZone = timeZone | ||
| 27 | let time = { (d: Date) in d.formatted(style) } | ||
| 28 | let current = scales?.current ?? .init(r: 0, s: 0, g: 0) | ||
| 29 | let asOf = scales.map { " as of \(time($0.asOf))" } ?? "" | ||
| 30 | |||
| 31 | // Storm-level bars (Kp 5 or more, G1) from now to 24 hours ahead. | ||
| 32 | let bar: TimeInterval = 3 * 3600 | ||
| 33 | let storms = kp.filter { $0.kp >= 5 && $0.start.addingTimeInterval(bar) > now && $0.start < now.addingTimeInterval(86_400) } | ||
| 34 | let visible = storms.filter { | ||
| 35 | visibleFrom(kp: $0.kp) <= abs(geomagneticLatitude) | ||
| 36 | && overlaps($0.start, bar, night) | ||
| 37 | } | ||
| 38 | let sky = geomagneticLatitude >= 0 ? "northern" : "southern" | ||
| 39 | |||
| 40 | // 1. Geomagnetic storm. | ||
| 41 | if let b = visible.max(by: { $0.kp < $1.kp }), let g = SpaceWeather.gScale(b.kp) { | ||
| 42 | let end = time(b.start.addingTimeInterval(bar)) | ||
| 43 | let when = b.start > now ? "forecast for \(time(b.start))–\(end)" : night.contains(now) ? "now" : "forecast until \(end)" | ||
| 44 | return "\(name(g)) storm (G\(g)) \(when), Kp \(String(format: "%.1f", b.kp)). Aurora possible low in the \(sky) sky." | ||
| 45 | } | ||
| 46 | if current.g >= 1 { | ||
| 47 | let needed = kpNeeded(at: geomagneticLatitude) | ||
| 48 | var text = "\(name(current.g)) storm (G\(current.g))\(asOf)." | ||
| 49 | if needed > 9 { | ||
| 50 | text += " Aurora at your latitude would need more than Kp 9." | ||
| 51 | } else if needed > 5 { | ||
| 52 | text += " Aurora at your latitude needs Kp \(String(format: "%.1f", needed))." | ||
| 53 | } else if kp.contains(where: { overlaps($0.start, bar, night) }) { | ||
| 54 | text += " Kp is forecast below 5 after dark." | ||
| 55 | } | ||
| 56 | return text | ||
| 57 | } | ||
| 58 | |||
| 59 | // 2. Solar radiation storm. | ||
| 60 | if current.s >= 1 { | ||
| 61 | return "\(name(current.s)) radiation storm (S\(current.s))\(asOf). It affects polar flights and satellites, not people on the ground." | ||
| 62 | } | ||
| 63 | |||
| 64 | // 3. Flare of class M or above in the last 6 hours, or a strong radio blackout now. | ||
| 65 | if let peak = xray.filter({ $0.time > now.addingTimeInterval(-6 * 3600) }).max(by: { $0.flux < $1.flux }), peak.flux >= 1e-5 { | ||
| 66 | return "\(name(rScale(peak.flux))) flare (\(XRay.flareClass(peak.flux))) at \(time(peak.time)). Brief radio blackout on Earth's day side." | ||
| 67 | } | ||
| 68 | if current.r >= 3 { | ||
| 69 | return "\(name(current.r)) radio blackout (R\(current.r))\(asOf). It affects radio on Earth's day side." | ||
| 70 | } | ||
| 71 | |||
| 72 | // 4. Quiet. | ||
| 73 | if let next = storms.min(by: { $0.start < $1.start }), let g = SpaceWeather.gScale(next.kp) { | ||
| 74 | return "Quiet now. \(name(g)) storm (G\(g)) forecast from \(time(next.start)), Kp \(String(format: "%.1f", next.kp))." | ||
| 75 | } | ||
| 76 | if let m = probabilities?.day(at: now)?.m { | ||
| 77 | return "Quiet. \(m)% chance of an M-class flare today." | ||
| 78 | } | ||
| 79 | return "Quiet. No storms, and no flare of class M or above in the last 6 hours." | ||
| 80 | } | ||
| 81 | |||
| 82 | private static func overlaps(_ start: Date, _ duration: TimeInterval, _ interval: DateInterval) -> Bool { | ||
| 83 | (DateInterval(start: start, duration: duration).intersection(with: interval)?.duration ?? 0) > 0 | ||
| 84 | } | ||
| 85 | |||
| 86 | private static func name(_ level: Int) -> String { | ||
| 87 | NOAAScales.levelName(level).capitalized | ||
| 88 | } | ||
| 89 | |||
| 90 | /// NOAA R scale for a flare's peak flux: R1 at M1, R2 at M5, R3 at X1, R4 at X10, R5 at X20. | ||
| 91 | private static func rScale(_ flux: Double) -> Int { | ||
| 92 | [1e-5, 5e-5, 1e-4, 1e-3, 2e-3].lastIndex { flux >= $0 }.map { $0 + 1 } ?? 0 | ||
| 93 | } | ||
| 94 | } | ||
Packages/MoonriseKit/Tests/FeedsTests/Fixtures/noaa-scales.json added +1
| @@ -0,0 +1 @@ | |||
| 1 | {"0": {"DateStamp": "2026-10-19", "TimeStamp": "21:13:00", "R": {"Scale": "0", "Text": "none", "MinorProb": null, "MajorProb": null}, "S": {"Scale": "0", "Text": "none", "Prob": null}, "G": {"Scale": "0", "Text": "none"}}, "1": {"DateStamp": "2026-10-19", "TimeStamp": "21:13:00", "R": {"Scale": null, "Text": null, "MinorProb": "25", "MajorProb": "5"}, "S": {"Scale": null, "Text": null, "Prob": "5"}, "G": {"Scale": "1", "Text": "minor"}}, "2": {"DateStamp": "2026-10-20", "TimeStamp": "00:00:00", "R": {"Scale": null, "Text": null, "MinorProb": "25", "MajorProb": "5"}, "S": {"Scale": null, "Text": null, "Prob": "5"}, "G": {"Scale": "1", "Text": "minor"}}, "3": {"DateStamp": "2026-10-21", "TimeStamp": "00:00:00", "R": {"Scale": null, "Text": null, "MinorProb": "25", "MajorProb": "5"}, "S": {"Scale": null, "Text": null, "Prob": "5"}, "G": {"Scale": "0", "Text": "none"}}, "-1": {"DateStamp": "2026-10-18", "TimeStamp": "21:13:00", "R": {"Scale": "1", "Text": "minor", "MinorProb": null, "MajorProb": null}, "S": {"Scale": "0", "Text": "none", "Prob": null}, "G": {"Scale": "0", "Text": "none"}}}\ No newline at end of file | ||
Packages/MoonriseKit/Tests/FeedsTests/Fixtures/solar-probabilities.json added +34
| @@ -0,0 +1,34 @@ | |||
| 1 | [ | ||
| 2 | { | ||
| 3 | "date": "2026-10-18T00:00:00", | ||
| 4 | "c_class_1_day": 85, | ||
| 5 | "c_class_2_day": 85, | ||
| 6 | "c_class_3_day": 85, | ||
| 7 | "m_class_1_day": 30, | ||
| 8 | "m_class_2_day": 30, | ||
| 9 | "m_class_3_day": 30, | ||
| 10 | "x_class_1_day": 10, | ||
| 11 | "x_class_2_day": 10, | ||
| 12 | "x_class_3_day": 10, | ||
| 13 | "10mev_protons_1_day": 5, | ||
| 14 | "10mev_protons_2_day": 5, | ||
| 15 | "10mev_protons_3_day": 5, | ||
| 16 | "polar_cap_absorption": "green" | ||
| 17 | }, | ||
| 18 | { | ||
| 19 | "date": "2026-10-19T00:00:00", | ||
| 20 | "c_class_1_day": 85, | ||
| 21 | "c_class_2_day": 85, | ||
| 22 | "c_class_3_day": 85, | ||
| 23 | "m_class_1_day": 25, | ||
| 24 | "m_class_2_day": 25, | ||
| 25 | "m_class_3_day": 25, | ||
| 26 | "x_class_1_day": 5, | ||
| 27 | "x_class_2_day": 5, | ||
| 28 | "x_class_3_day": 5, | ||
| 29 | "10mev_protons_1_day": 5, | ||
| 30 | "10mev_protons_2_day": 5, | ||
| 31 | "10mev_protons_3_day": 5, | ||
| 32 | "polar_cap_absorption": "green" | ||
| 33 | } | ||
| 34 | ] | ||
Packages/MoonriseKit/Tests/FeedsTests/NOAAScalesTests.swift added +52
| @@ -0,0 +1,52 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Feeds | ||
| 4 | |||
| 5 | // Fixtures saved from the live SWPC feeds on 2026-10-19. | ||
| 6 | |||
| 7 | @Test func decodesCurrentAndPastDayScales() throws { | ||
| 8 | let url = try #require(Bundle.module.url(forResource: "Fixtures/noaa-scales", withExtension: "json")) | ||
| 9 | let s = try NOAAScales.decode(Data(contentsOf: url)) | ||
| 10 | #expect(s.current == NOAAScales.Levels(r: 0, s: 0, g: 0)) | ||
| 11 | #expect(s.past24h == NOAAScales.Levels(r: 1, s: 0, g: 0)) | ||
| 12 | #expect(s.asOf == (try Date("2026-10-19T21:13:00Z", strategy: .iso8601))) | ||
| 13 | } | ||
| 14 | |||
| 15 | @Test func scaleLevelNames() { | ||
| 16 | #expect(NOAAScales.levelName(0) == "none") | ||
| 17 | #expect(NOAAScales.levelName(3) == "strong") | ||
| 18 | #expect(NOAAScales.levelName(5) == "extreme") | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test func decodesNewestProbabilities() throws { | ||
| 22 | let url = try #require(Bundle.module.url(forResource: "Fixtures/solar-probabilities", withExtension: "json")) | ||
| 23 | let p = try SolarProbabilities.decode(Data(contentsOf: url)) | ||
| 24 | #expect(p.issued == (try Date("2026-10-19T00:00:00Z", strategy: .iso8601))) | ||
| 25 | #expect(p.days.count == 3) | ||
| 26 | #expect(p.days[0] == SolarProbabilities.Day(c: 85, m: 25, x: 5, proton: 5)) | ||
| 27 | } | ||
| 28 | |||
| 29 | @Test func probabilitiesForADate() throws { | ||
| 30 | let url = try #require(Bundle.module.url(forResource: "Fixtures/solar-probabilities", withExtension: "json")) | ||
| 31 | let p = try SolarProbabilities.decode(Data(contentsOf: url)) | ||
| 32 | let issued = p.issued | ||
| 33 | #expect(p.day(at: issued.addingTimeInterval(23 * 3600)) == p.days[0]) | ||
| 34 | #expect(p.day(at: issued.addingTimeInterval(25 * 3600)) == p.days[1]) | ||
| 35 | #expect(p.day(at: issued.addingTimeInterval(-3600)) == nil) | ||
| 36 | #expect(p.day(at: issued.addingTimeInterval(73 * 3600)) == nil) | ||
| 37 | } | ||
| 38 | |||
| 39 | @Test func snapshotRoundTripsThroughDisk() throws { | ||
| 40 | let file = FileManager.default.temporaryDirectory.appending(path: "sun-\(UUID().uuidString)/SunSnapshot.json") | ||
| 41 | let snapshot = SunSnapshot( | ||
| 42 | fetched: Date(timeIntervalSince1970: 1_800_000_000), | ||
| 43 | kp: [KpBar(start: Date(timeIntervalSince1970: 1_800_000_000), kp: 5.33, predicted: true)], | ||
| 44 | xray: [XRaySample(time: Date(timeIntervalSince1970: 1_800_000_060), flux: 2.4e-5)], | ||
| 45 | ovation: Ovation(forecastTime: Date(timeIntervalSince1970: 1_800_000_000), cells: [.init(lon: 0, lat: 65, probability: 12)]), | ||
| 46 | scales: NOAAScales(asOf: Date(timeIntervalSince1970: 1_800_000_000), current: .init(r: 1, s: 0, g: 2), past24h: .init(r: 2, s: 0, g: 2)), | ||
| 47 | probabilities: SolarProbabilities(issued: Date(timeIntervalSince1970: 1_799_971_200), days: [.init(c: 85, m: 25, x: 5, proton: 5)]) | ||
| 48 | ) | ||
| 49 | try snapshot.save(to: file) | ||
| 50 | #expect(SunSnapshot.load(from: file) == snapshot) | ||
| 51 | #expect(SunSnapshot.load(from: file.deletingLastPathComponent().appending(path: "missing.json")) == nil) | ||
| 52 | } | ||
Packages/MoonriseKit/Tests/FeedsTests/SunSummaryTests.swift added +126
| @@ -0,0 +1,126 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Feeds | ||
| 4 | |||
| 5 | private func at(_ s: String) -> Date { try! Date(s, strategy: .iso8601) } | ||
| 6 | |||
| 7 | private let now = at("2026-10-19T15:00:00Z") | ||
| 8 | private let night = DateInterval(start: at("2026-10-19T23:00:00Z"), end: at("2026-10-20T11:00:00Z")) | ||
| 9 | /// Chicago is at about 50.7° geomagnetic. | ||
| 10 | private let chicago = 50.7 | ||
| 11 | |||
| 12 | private func scales(r: Int = 0, s: Int = 0, g: Int = 0) -> NOAAScales { | ||
| 13 | NOAAScales(asOf: at("2026-10-19T14:55:00Z"), current: .init(r: r, s: s, g: g), past24h: .init(r: r, s: s, g: g)) | ||
| 14 | } | ||
| 15 | |||
| 16 | private let probabilities = SolarProbabilities( | ||
| 17 | issued: at("2026-10-19T00:00:00Z"), | ||
| 18 | days: [.init(c: 85, m: 25, x: 5, proton: 5), .init(c: 80, m: 20, x: 5, proton: 5), .init(c: 80, m: 20, x: 5, proton: 5)] | ||
| 19 | ) | ||
| 20 | |||
| 21 | private let quietKp = [ | ||
| 22 | KpBar(start: at("2026-10-19T12:00:00Z"), kp: 2.33, predicted: false), | ||
| 23 | KpBar(start: at("2026-10-20T00:00:00Z"), kp: 3.0, predicted: true), | ||
| 24 | ] | ||
| 25 | |||
| 26 | private let quietXRay = [XRaySample(time: at("2026-10-19T14:00:00Z"), flux: 3e-6)] | ||
| 27 | |||
| 28 | private func summary( | ||
| 29 | scales s: NOAAScales? = scales(), kp: [KpBar] = quietKp, xray: [XRaySample] = quietXRay, | ||
| 30 | latitude: Double = chicago, night n: DateInterval = night, at date: Date = now | ||
| 31 | ) -> String? { | ||
| 32 | SunSummary.text( | ||
| 33 | scales: s, kp: kp, probabilities: probabilities, xray: xray, | ||
| 34 | geomagneticLatitude: latitude, night: n, at: date, | ||
| 35 | timeZone: TimeZone(identifier: "UTC")!, locale: Locale(identifier: "en_GB") | ||
| 36 | ) | ||
| 37 | } | ||
| 38 | |||
| 39 | @Test func quietNamesTodaysFlareChance() { | ||
| 40 | #expect(summary() == "Quiet. 25% chance of an M-class flare today.") | ||
| 41 | } | ||
| 42 | |||
| 43 | @Test func quietWithoutProbabilities() { | ||
| 44 | let text = SunSummary.text( | ||
| 45 | scales: scales(), kp: quietKp, probabilities: nil, xray: quietXRay, | ||
| 46 | geomagneticLatitude: chicago, night: night, at: now, | ||
| 47 | timeZone: TimeZone(identifier: "UTC")!, locale: Locale(identifier: "en_GB") | ||
| 48 | ) | ||
| 49 | #expect(text == "Quiet. No storms, and no flare of class M or above in the last 6 hours.") | ||
| 50 | } | ||
| 51 | |||
| 52 | @Test func nothingLoadedGivesNoSummary() { | ||
| 53 | #expect(summary(scales: nil, kp: [], xray: []) == nil) | ||
| 54 | } | ||
| 55 | |||
| 56 | @Test func stormForecastAfterDarkAtYourLatitude() { | ||
| 57 | let kp = quietKp + [KpBar(start: at("2026-10-20T03:00:00Z"), kp: 5.33, predicted: true)] | ||
| 58 | #expect(summary(kp: kp) == "Minor storm (G1) forecast for 3:00–6:00, Kp 5.3. Aurora possible low in the northern sky.") | ||
| 59 | } | ||
| 60 | |||
| 61 | @Test func stormNowAndDark() { | ||
| 62 | let kp = [KpBar(start: at("2026-10-20T00:00:00Z"), kp: 6.67, predicted: true)] | ||
| 63 | let text = summary(scales: scales(g: 2), kp: kp, at: at("2026-10-20T01:00:00Z")) | ||
| 64 | #expect(text == "Moderate storm (G2) now, Kp 6.7. Aurora possible low in the northern sky.") | ||
| 65 | } | ||
| 66 | |||
| 67 | @Test func stormUnderwayBeforeDarkRunsIntoTheNight() { | ||
| 68 | let kp = [KpBar(start: at("2026-10-19T21:00:00Z"), kp: 5.0, predicted: true)] | ||
| 69 | let text = summary(kp: kp, at: at("2026-10-19T21:30:00Z")) | ||
| 70 | #expect(text == "Minor storm (G1) forecast until 0:00, Kp 5.0. Aurora possible low in the northern sky.") | ||
| 71 | } | ||
| 72 | |||
| 73 | @Test func southernObserverLooksSouth() { | ||
| 74 | let kp = [KpBar(start: at("2026-10-20T03:00:00Z"), kp: 5.33, predicted: true)] | ||
| 75 | #expect(summary(kp: kp, latitude: -55)?.hasSuffix("Aurora possible low in the southern sky.") == true) | ||
| 76 | } | ||
| 77 | |||
| 78 | @Test func stormTooFarFromThePoleIsASecondClause() { | ||
| 79 | // At 40° geomagnetic, Kp 5.3 does not reach: the oval's visible edge is at 49.6°. | ||
| 80 | let kp = quietKp + [KpBar(start: at("2026-10-20T03:00:00Z"), kp: 5.33, predicted: true)] | ||
| 81 | #expect(summary(kp: kp, latitude: 40) == "Quiet now. Minor storm (G1) forecast from 3:00, Kp 5.3.") | ||
| 82 | } | ||
| 83 | |||
| 84 | @Test func daytimeStormIsASecondClause() { | ||
| 85 | let kp = quietKp + [KpBar(start: at("2026-10-19T18:00:00Z"), kp: 5.33, predicted: true)] | ||
| 86 | #expect(summary(kp: kp) == "Quiet now. Minor storm (G1) forecast from 18:00, Kp 5.3.") | ||
| 87 | } | ||
| 88 | |||
| 89 | @Test func stormNowOutOfReach() { | ||
| 90 | #expect(summary(scales: scales(g: 1), latitude: 40) == "Minor storm (G1) as of 14:55. Aurora at your latitude would need more than Kp 9.") | ||
| 91 | #expect(summary(scales: scales(g: 1), latitude: 45) == "Minor storm (G1) as of 14:55. Aurora at your latitude needs Kp 7.6.") | ||
| 92 | } | ||
| 93 | |||
| 94 | @Test func stormNowInReachButOverBeforeDark() { | ||
| 95 | #expect(summary(scales: scales(g: 1)) == "Minor storm (G1) as of 14:55. Kp is forecast below 5 after dark.") | ||
| 96 | } | ||
| 97 | |||
| 98 | @Test func radiationStorm() { | ||
| 99 | #expect(summary(scales: scales(s: 1)) == "Minor radiation storm (S1) as of 14:55. It affects polar flights and satellites, not people on the ground.") | ||
| 100 | } | ||
| 101 | |||
| 102 | @Test func recentFlare() { | ||
| 103 | let xray = quietXRay + [XRaySample(time: at("2026-10-19T14:05:00Z"), flux: 1.2e-4)] | ||
| 104 | #expect(summary(xray: xray) == "Strong flare (X1.2) at 14:05. Brief radio blackout on Earth's day side.") | ||
| 105 | } | ||
| 106 | |||
| 107 | @Test func flareOlderThanSixHoursIsQuiet() { | ||
| 108 | let xray = quietXRay + [XRaySample(time: at("2026-10-19T08:30:00Z"), flux: 2e-5)] | ||
| 109 | #expect(summary(xray: xray) == "Quiet. 25% chance of an M-class flare today.") | ||
| 110 | } | ||
| 111 | |||
| 112 | @Test func radioBlackoutWithoutXRayData() { | ||
| 113 | #expect(summary(scales: scales(r: 3), xray: []) == "Strong radio blackout (R3) as of 14:55. It affects radio on Earth's day side.") | ||
| 114 | } | ||
| 115 | |||
| 116 | @Test func stormOutranksRadiationAndFlare() { | ||
| 117 | let xray = [XRaySample(time: at("2026-10-19T14:05:00Z"), flux: 5e-5)] | ||
| 118 | #expect(summary(scales: scales(r: 2, s: 1, g: 1), xray: xray)?.hasPrefix("Minor storm (G1)") == true) | ||
| 119 | #expect(summary(scales: scales(r: 2, s: 1), xray: xray)?.hasPrefix("Minor radiation storm (S1)") == true) | ||
| 120 | #expect(summary(scales: scales(r: 2), xray: xray)?.hasPrefix("Moderate flare (M5.0)") == true) | ||
| 121 | } | ||
| 122 | |||
| 123 | @Test func visibleLatitudeMatchesServer() { | ||
| 124 | // server/src/aurora.ts: visibleFrom(kp) = 66.5 - 2.04 * kp - 6 | ||
| 125 | #expect(abs(SunSummary.visibleFrom(kp: 5) - 50.3) < 1e-9) | ||
| 126 | } | ||