Packages/MoonriseKit/Sources/Feeds/SpaceWeather.swift
55 lines · 2295 bytes
1import Foundation
2
3/// Planetary Kp from NOAA SWPC (US government data).
4public struct SpaceWeather: Sendable, Equatable {
5 /// Latest 1-minute estimated Kp.
6 public var kpNow: Double
7 public var observedAt: Date
8 /// Highest predicted 3-hour Kp in the next 24 hours, if any.
9 public var kpNext24h: Double?
10
11 public static let nowURL = URL(string: "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json")!
12 public static let forecastURL = URL(string: "https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json")!
13
14 struct Minute: Decodable {
15 var time_tag: String
16 var estimated_kp: Double
17 }
18
19 struct Forecast: Decodable {
20 var time_tag: String
21 var kp: Double
22 var observed: String
23 }
24
25 /// SWPC time tags are UTC without a zone designator.
26 static func parse(_ tag: String) -> Date? {
27 try? Date(tag + "Z", strategy: .iso8601)
28 }
29
30 public static func decode(now: Data, forecast: Data, at date: Date) throws -> SpaceWeather {
31 let minutes = try JSONDecoder().decode([Minute].self, from: now)
32 guard let last = minutes.last, let observedAt = parse(last.time_tag) else {
33 throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "empty Kp feed"))
34 }
35 let rows = try JSONDecoder().decode([Forecast].self, from: forecast)
36 let horizon = date.addingTimeInterval(24 * 3600)
37 let next = rows
38 .filter { $0.observed == "predicted" }
39 .filter { row in parse(row.time_tag).map { $0 >= date.addingTimeInterval(-3 * 3600) && $0 <= horizon } ?? false }
40 .map(\.kp)
41 .max()
42 return SpaceWeather(kpNow: last.estimated_kp, observedAt: observedAt, kpNext24h: next)
43 }
44
45 public static func fetch(session: URLSession = .shared, at date: Date = .now) async throws -> SpaceWeather {
46 async let now = session.data(from: nowURL).0
47 async let forecast = session.data(from: forecastURL).0
48 return try decode(now: await now, forecast: await forecast, at: date)
49 }
50
51 /// NOAA G-scale for a Kp value: G1 at Kp 5 through G5 at Kp 9.
52 public static func gScale(_ kp: Double) -> Int? {
53 kp >= 5 ? min(Int(kp.rounded(.down)) - 4, 5) : nil
54 }
55}