Packages/MoonriseKit/Sources/Feeds/NOAAScales.swift
137 lines · 5344 bytes
14 symbols in this file
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}