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

Commit 7fc9a61cd3

7fc9a61cd30d8b2f39c811a5bda2bd2bc49c8b06

parent: 3b8c40e96a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:44 UTC

Sun tab: summary, NOAA scales, flare probabilities, offline snapshot

- Feeds: NOAAScales (noaa-scales.json, current and 24 h max R/S/G),
  SolarProbabilities (solar_probabilities.json, newest row), SunSummary
  (rule-based summary), SunSnapshot (Codable, saved to disk).
- Sun tab: summary with source and age replaces the intro, which moves to
  the footer; R/S/G strip; C/M/X/proton table under the X-ray chart.
- The last complete feed set is saved to Application Support and the last
  SDO image per wavelength to Caches; both are shown with their age when a
  load fails.
- The 3D view's caption uses SunSummary in place of SolarWeather.summary.

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 @@
1import Charts 1import Charts
2import CoreLocation 2import CoreLocation
3import Feeds 3import Feeds
4import SkyEngine
4import SkyRenderer 5import SkyRenderer
5import SwiftUI 6import SwiftUI
6 7
7struct SunScreen: View { 8struct 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
307enum 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 @@
1import 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).
5public 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.
67public 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).
40public struct KpBar: Sendable, Equatable, Identifiable { 40public 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.
60public struct XRaySample: Sendable, Equatable, Identifiable { 60public 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.
99public struct Ovation: Sendable { 99public 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 @@
1import Foundation
2
3/// The NOAA feeds behind the Sun tab, saved after each complete load so the tab has something to show offline.
4public 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 @@
1import 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.
6public 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 @@
1import Foundation
2import 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 @@
1import Foundation
2import Testing
3@testable import Feeds
4
5private func at(_ s: String) -> Date { try! Date(s, strategy: .iso8601) }
6
7private let now = at("2026-10-19T15:00:00Z")
8private 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.
10private let chicago = 50.7
11
12private 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
16private 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
21private 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
26private let quietXRay = [XRaySample(time: at("2026-10-19T14:00:00Z"), flux: 3e-6)]
27
28private 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}