krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Packages/MoonriseKit/Sources/Feeds/NOAAScales.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/Packages/MoonriseKit/Sources/Feeds/NOAAScales.swift history · blame · raw

137 lines · 5344 bytes

  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}