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/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}