Commit 8631c80826
Verified · cmc
Layout: unified · split
App/Sources/SkyModel.swift +20
| @@ -1,4 +1,5 @@ | |||
| 1 | import CoreLocation | 1 | import CoreLocation |
| 2 | import Feeds | ||
| 2 | import Foundation | 3 | import Foundation |
| 3 | import Observation | 4 | import Observation |
| 4 | import SkyEngine | 5 | import SkyEngine |
| @@ -17,6 +18,10 @@ actor Astronomer { | |||
| 17 | let rs = SkyEngine.riseSet(body, after: date, from: observer) | 18 | let rs = SkyEngine.riseSet(body, after: date, from: observer) |
| 18 | return BodyDetails(body: body, position: h, distanceAU: eq.distance, magnitude: i.magnitude, phaseFraction: i.phaseFraction, rise: rs.rise, set: rs.set) | 19 | return BodyDetails(body: body, position: h, distanceAU: eq.distance, magnitude: i.magnitude, phaseFraction: i.phaseFraction, rise: rs.rise, set: rs.set) |
| 19 | } | 20 | } |
| 21 | |||
| 22 | func tonight(at date: Date, from observer: Observer) throws -> Tonight { | ||
| 23 | try Tonight(at: date, from: observer) | ||
| 24 | } | ||
| 20 | } | 25 | } |
| 21 | 26 | ||
| 22 | struct BodyDetails: Sendable { | 27 | struct BodyDetails: Sendable { |
| @@ -40,6 +45,9 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | |||
| 40 | private(set) var hasLocation = false | 45 | private(set) var hasLocation = false |
| 41 | private(set) var scene: SkyScene? | 46 | private(set) var scene: SkyScene? |
| 42 | private(set) var lastBuild: Duration = .zero | 47 | private(set) var lastBuild: Duration = .zero |
| 48 | private(set) var tonight: Tonight? | ||
| 49 | private(set) var spaceWeather: SpaceWeather? | ||
| 50 | private var weatherFetched: Date? | ||
| 43 | /// Hours from now shown by the time scrubber. | 51 | /// Hours from now shown by the time scrubber. |
| 44 | var offsetHours = 0.0 { didSet { refresh() } } | 52 | var offsetHours = 0.0 { didSet { refresh() } } |
| 45 | var displayedDate: Date { .now.addingTimeInterval(offsetHours * 3600) } | 53 | var displayedDate: Date { .now.addingTimeInterval(offsetHours * 3600) } |
| @@ -74,11 +82,23 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | |||
| 74 | scene = try? await astronomer.scene(at: date, from: observer) | 82 | scene = try? await astronomer.scene(at: date, from: observer) |
| 75 | lastBuild = clock.now - start | 83 | lastBuild = clock.now - start |
| 76 | if let scene { self.scene = scene } | 84 | if let scene { self.scene = scene } |
| 85 | if offsetHours == 0 { tonight = try? await astronomer.tonight(at: date, from: observer) } | ||
| 77 | building = false | 86 | building = false |
| 78 | if pending { pending = false; refresh() } | 87 | if pending { pending = false; refresh() } |
| 79 | } | 88 | } |
| 80 | } | 89 | } |
| 81 | 90 | ||
| 91 | /// SWPC asks for no more than once a minute; this checks every 10. | ||
| 92 | func refreshSpaceWeather() async { | ||
| 93 | if let weatherFetched, Date.now.timeIntervalSince(weatherFetched) < 600 { return } | ||
| 94 | do { | ||
| 95 | spaceWeather = try await SpaceWeather.fetch() | ||
| 96 | weatherFetched = .now | ||
| 97 | } catch { | ||
| 98 | log.error("Space weather failed: \(error.localizedDescription, privacy: .public)") | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 82 | func details(of body: Body) async -> BodyDetails? { | 102 | func details(of body: Body) async -> BodyDetails? { |
| 83 | try? await astronomer.details(of: body, at: displayedDate, from: observer) | 103 | try? await astronomer.details(of: body, at: displayedDate, from: observer) |
| 84 | } | 104 | } |
App/Sources/SkyScreen.swift +7 −18
| @@ -35,13 +35,18 @@ struct SkyScreen: View { | |||
| 35 | } | 35 | } |
| 36 | VStack(spacing: 0) { | 36 | VStack(spacing: 0) { |
| 37 | header | 37 | header |
| 38 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } | ||
| 38 | Spacer() | 39 | Spacer() |
| 39 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.bottom, 10) } | ||
| 40 | scrubber | ||
| 41 | } | 40 | } |
| 42 | } | 41 | } |
| 43 | } | 42 | } |
| 44 | .foregroundStyle(ink) | 43 | .foregroundStyle(ink) |
| 44 | .sheet(isPresented: .constant(true)) { | ||
| 45 | TodaySheet(model: model, ink: ink, background: style.ground) | ||
| 46 | .presentationDetents([.height(110), .medium, .large]) | ||
| 47 | .presentationBackgroundInteraction(.enabled(upThrough: .medium)) | ||
| 48 | .interactiveDismissDisabled() | ||
| 49 | } | ||
| 45 | .onChange(of: model.offsetHours) { select(selection) } | 50 | .onChange(of: model.offsetHours) { select(selection) } |
| 46 | .task { | 51 | .task { |
| 47 | model.start() | 52 | model.start() |
| @@ -110,22 +115,6 @@ struct SkyScreen: View { | |||
| 110 | .padding(.top, 8) | 115 | .padding(.top, 8) |
| 111 | } | 116 | } |
| 112 | 117 | ||
| 113 | private var scrubber: some View { | ||
| 114 | HStack(spacing: 12) { | ||
| 115 | Text(model.displayedDate.formatted(date: .omitted, time: .shortened)) | ||
| 116 | .font(.system(size: 13).monospacedDigit()) | ||
| 117 | .frame(width: 70, alignment: .leading) | ||
| 118 | Slider(value: $model.offsetHours, in: -12...12, step: 0.25) | ||
| 119 | .tint(ink) | ||
| 120 | Button("Now") { model.offsetHours = 0 } | ||
| 121 | .font(.system(size: 13, weight: .medium)) | ||
| 122 | .disabled(model.offsetHours == 0) | ||
| 123 | } | ||
| 124 | .padding(.horizontal, 20) | ||
| 125 | .padding(.vertical, 12) | ||
| 126 | .background(style.ground.opacity(0.9)) | ||
| 127 | } | ||
| 128 | |||
| 129 | @ViewBuilder | 118 | @ViewBuilder |
| 130 | private func card(for object: SkyObject) -> some View { | 119 | private func card(for object: SkyObject) -> some View { |
| 131 | VStack(alignment: .leading, spacing: 8) { | 120 | VStack(alignment: .leading, spacing: 8) { |
App/Sources/TodaySheet.swift added +142
| @@ -0,0 +1,142 @@ | |||
| 1 | import Feeds | ||
| 2 | import SkyEngine | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// Pull-up sheet over the sky: time scrubber, then tonight's summary. | ||
| 6 | struct TodaySheet: View { | ||
| 7 | @Bindable var model: SkyModel | ||
| 8 | var ink: Color | ||
| 9 | var background: Color | ||
| 10 | |||
| 11 | var body: some View { | ||
| 12 | ScrollView { | ||
| 13 | VStack(alignment: .leading, spacing: 0) { | ||
| 14 | scrubber | ||
| 15 | Text(summary).font(.system(size: 13)).opacity(0.7).padding(.bottom, 16) | ||
| 16 | if let t = model.tonight { | ||
| 17 | section("Moon") { moon(t) } | ||
| 18 | section("Planets tonight") { planets(t) } | ||
| 19 | section("Sun") { sun(t) } | ||
| 20 | } | ||
| 21 | section("Space weather") { weather } | ||
| 22 | Text("Space weather data: NOAA Space Weather Prediction Center.") | ||
| 23 | .font(.system(size: 11)).opacity(0.5).padding(.top, 8) | ||
| 24 | } | ||
| 25 | .padding(.horizontal, 20) | ||
| 26 | .padding(.top, 18) | ||
| 27 | } | ||
| 28 | .foregroundStyle(ink) | ||
| 29 | .presentationBackground(background) | ||
| 30 | .task { | ||
| 31 | while !Task.isCancelled { | ||
| 32 | await model.refreshSpaceWeather() | ||
| 33 | try? await Task.sleep(for: .seconds(600)) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | private var summary: String { | ||
| 39 | var parts: [String] = [] | ||
| 40 | if let t = model.tonight { | ||
| 41 | parts.append("Moon \(Int((t.moonIllumination * 100).rounded()))%") | ||
| 42 | let up = t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized } | ||
| 43 | parts.append(up.isEmpty ? "No bright planets tonight" : up.joined(separator: ", ")) | ||
| 44 | } | ||
| 45 | if let w = model.spaceWeather { parts.append(String(format: "Kp %.1f", w.kpNow)) } | ||
| 46 | return parts.joined(separator: " · ") | ||
| 47 | } | ||
| 48 | |||
| 49 | private var scrubber: some View { | ||
| 50 | HStack(spacing: 12) { | ||
| 51 | Text(model.displayedDate.formatted(date: .omitted, time: .shortened)) | ||
| 52 | .font(.system(size: 13).monospacedDigit()) | ||
| 53 | .frame(width: 70, alignment: .leading) | ||
| 54 | Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink) | ||
| 55 | Button("Now") { model.offsetHours = 0 } | ||
| 56 | .font(.system(size: 13, weight: .medium)) | ||
| 57 | .opacity(model.offsetHours == 0 ? 0.4 : 1) | ||
| 58 | } | ||
| 59 | .padding(.bottom, 10) | ||
| 60 | } | ||
| 61 | |||
| 62 | private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { | ||
| 63 | VStack(alignment: .leading, spacing: 8) { | ||
| 64 | Text(title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) | ||
| 65 | content() | ||
| 66 | } | ||
| 67 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 68 | .padding(.vertical, 12) | ||
| 69 | .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) } | ||
| 70 | } | ||
| 71 | |||
| 72 | private func moon(_ t: Tonight) -> some View { | ||
| 73 | let quarter = switch t.nextQuarter.quarter { | ||
| 74 | case .new: "New moon" | ||
| 75 | case .firstQuarter: "First quarter" | ||
| 76 | case .full: "Full moon" | ||
| 77 | case .lastQuarter: "Last quarter" | ||
| 78 | } | ||
| 79 | return rows([ | ||
| 80 | ("Illuminated", "\(Int((t.moonIllumination * 100).rounded()))%, \(t.moonPhase < 180 ? "waxing" : "waning")"), | ||
| 81 | (quarter, t.nextQuarter.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())), | ||
| 82 | ]) | ||
| 83 | } | ||
| 84 | |||
| 85 | private func planets(_ t: Tonight) -> some View { | ||
| 86 | rows(t.planets.map { p in | ||
| 87 | let name = p.body.rawValue.capitalized | ||
| 88 | guard let best = p.best else { return (name, "not well placed tonight") } | ||
| 89 | var parts: [String] = [] | ||
| 90 | if p.now.altitude > 0 { | ||
| 91 | parts.append(String(format: "up now, %.0f° %@", p.now.altitude, compass(p.now.azimuth))) | ||
| 92 | } else if let rise = p.rise, rise < t.end { | ||
| 93 | parts.append("rises \(time(rise))") | ||
| 94 | } | ||
| 95 | // A best point at the end of the window means it is still climbing at dawn. | ||
| 96 | if t.end.timeIntervalSince(best.date) < 900 { | ||
| 97 | parts.append(String(format: "%.0f° %@ by dawn", best.position.altitude, compass(best.position.azimuth))) | ||
| 98 | } else { | ||
| 99 | parts.append(String(format: "highest %@ at %.0f°", time(best.date), best.position.altitude)) | ||
| 100 | } | ||
| 101 | return (name, parts.joined(separator: " · ")) | ||
| 102 | }) | ||
| 103 | } | ||
| 104 | |||
| 105 | private func sun(_ t: Tonight) -> some View { | ||
| 106 | rows([ | ||
| 107 | ("Sunset", t.sunset.map(time) ?? "already set"), | ||
| 108 | ("Sunrise", t.sunrise.map(time) ?? "–"), | ||
| 109 | ]) | ||
| 110 | } | ||
| 111 | |||
| 112 | @ViewBuilder | ||
| 113 | private var weather: some View { | ||
| 114 | if let w = model.spaceWeather { | ||
| 115 | rows([ | ||
| 116 | ("Kp now", String(format: "%.1f%@", w.kpNow, SpaceWeather.gScale(w.kpNow).map { " (G\($0) storm)" } ?? "")), | ||
| 117 | ("Next 24 h", w.kpNext24h.map { String(format: "up to Kp %.1f%@", $0, SpaceWeather.gScale($0).map { " (G\($0))" } ?? "") } ?? "–"), | ||
| 118 | ("Updated", time(w.observedAt)), | ||
| 119 | ]) | ||
| 120 | } else { | ||
| 121 | Text("Loading").font(.system(size: 13)).opacity(0.6) | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | private func rows(_ items: [(String, String)]) -> some View { | ||
| 126 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | ||
| 127 | ForEach(items, id: \.0) { item in | ||
| 128 | GridRow { | ||
| 129 | Text(item.0).opacity(0.7) | ||
| 130 | Text(item.1).monospacedDigit() | ||
| 131 | } | ||
| 132 | .font(.system(size: 14)) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .shortened) } | ||
| 138 | |||
| 139 | private func compass(_ azimuth: Double) -> String { | ||
| 140 | ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8] | ||
| 141 | } | ||
| 142 | } | ||
App/project.yml +2
| @@ -16,6 +16,8 @@ targets: | |||
| 16 | product: SkyEngine | 16 | product: SkyEngine |
| 17 | - package: MoonriseKit | 17 | - package: MoonriseKit |
| 18 | product: SkyRenderer | 18 | product: SkyRenderer |
| 19 | - package: MoonriseKit | ||
| 20 | product: Feeds | ||
| 19 | entitlements: | 21 | entitlements: |
| 20 | path: Sources/Moonrise.entitlements | 22 | path: Sources/Moonrise.entitlements |
| 21 | properties: | 23 | properties: |
Packages/MoonriseKit/Package.swift +3
| @@ -7,6 +7,7 @@ let package = Package( | |||
| 7 | products: [ | 7 | products: [ |
| 8 | .library(name: "SkyEngine", targets: ["SkyEngine"]), | 8 | .library(name: "SkyEngine", targets: ["SkyEngine"]), |
| 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), | 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), |
| 10 | .library(name: "Feeds", targets: ["Feeds"]), | ||
| 10 | ], | 11 | ], |
| 11 | targets: [ | 12 | targets: [ |
| 12 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy | 13 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy |
| @@ -21,7 +22,9 @@ let package = Package( | |||
| 21 | dependencies: ["SkyEngine"], | 22 | dependencies: ["SkyEngine"], |
| 22 | resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json")] | 23 | resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json")] |
| 23 | ), | 24 | ), |
| 25 | .target(name: "Feeds"), | ||
| 24 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), | 26 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
| 27 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"]), | ||
| 25 | .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]), | 28 | .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]), |
| 26 | ] | 29 | ] |
| 27 | ) | 30 | ) |
Packages/MoonriseKit/Sources/Feeds/SpaceWeather.swift added +55
| @@ -0,0 +1,55 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Planetary Kp from NOAA SWPC (US government data). | ||
| 4 | public 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 | } | ||
Packages/MoonriseKit/Sources/SkyEngine/Tonight.swift added +61
| @@ -0,0 +1,61 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | public struct PlanetTonight: Sendable { | ||
| 4 | public var body: Body | ||
| 5 | public var magnitude: Double | ||
| 6 | public var now: Horizontal | ||
| 7 | /// Highest point during the night, if it gets above 10°. | ||
| 8 | public var best: (date: Date, position: Horizontal)? | ||
| 9 | public var rise: Date? | ||
| 10 | public var set: Date? | ||
| 11 | } | ||
| 12 | |||
| 13 | public struct Tonight: Sendable { | ||
| 14 | /// Darkness window: from now if it is already dark, else from the next sunset. | ||
| 15 | public var start: Date | ||
| 16 | public var end: Date | ||
| 17 | public var sunset: Date? | ||
| 18 | public var sunrise: Date? | ||
| 19 | public var moonIllumination: Double | ||
| 20 | public var moonPhase: Double | ||
| 21 | public var nextQuarter: (quarter: SkyEngine.MoonQuarter, date: Date) | ||
| 22 | /// Naked-eye planets, those visible tonight first, brightest first. | ||
| 23 | public var planets: [PlanetTonight] | ||
| 24 | |||
| 25 | public init(at date: Date, from observer: Observer) throws { | ||
| 26 | let sunNow = try SkyEngine.horizontal(.sun, at: date, from: observer, refraction: true) | ||
| 27 | let sunToday = SkyEngine.riseSet(.sun, after: date, from: observer) | ||
| 28 | if sunNow.altitude < -0.8 { | ||
| 29 | start = date | ||
| 30 | sunset = nil | ||
| 31 | } else { | ||
| 32 | sunset = sunToday.set | ||
| 33 | start = sunToday.set ?? date | ||
| 34 | } | ||
| 35 | sunrise = SkyEngine.riseSet(.sun, after: start, from: observer).rise | ||
| 36 | end = sunrise ?? start.addingTimeInterval(12 * 3600) | ||
| 37 | |||
| 38 | moonIllumination = try SkyEngine.illumination(.moon, at: date).phaseFraction | ||
| 39 | moonPhase = try SkyEngine.moonPhase(at: date) | ||
| 40 | nextQuarter = try SkyEngine.nextMoonQuarter(after: date) | ||
| 41 | |||
| 42 | let samples = stride(from: start.timeIntervalSince1970, through: end.timeIntervalSince1970, by: 900).map(Date.init(timeIntervalSince1970:)) | ||
| 43 | planets = try [Body.mercury, .venus, .mars, .jupiter, .saturn].map { body in | ||
| 44 | var best: (Date, Horizontal)? | ||
| 45 | for s in samples { | ||
| 46 | let h = try SkyEngine.horizontal(body, at: s, from: observer, refraction: true) | ||
| 47 | if h.altitude > 10, h.altitude > best?.1.altitude ?? -90 { best = (s, h) } | ||
| 48 | } | ||
| 49 | let rs = SkyEngine.riseSet(body, after: date, from: observer) | ||
| 50 | return PlanetTonight( | ||
| 51 | body: body, | ||
| 52 | magnitude: try SkyEngine.illumination(body, at: date).magnitude, | ||
| 53 | now: try SkyEngine.horizontal(body, at: date, from: observer, refraction: true), | ||
| 54 | best: best.map { (date: $0.0, position: $0.1) }, | ||
| 55 | rise: rs.rise, | ||
| 56 | set: rs.set | ||
| 57 | ) | ||
| 58 | } | ||
| 59 | .sorted { ($0.best != nil ? 0 : 1, $0.magnitude) < ($1.best != nil ? 0 : 1, $1.magnitude) } | ||
| 60 | } | ||
| 61 | } | ||
Packages/MoonriseKit/Tests/FeedsTests/SpaceWeatherTests.swift added +30
| @@ -0,0 +1,30 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Feeds | ||
| 4 | |||
| 5 | // Shapes copied from the live SWPC feeds on 2026-10-17. | ||
| 6 | let minutes = Data(""" | ||
| 7 | [{"time_tag": "2026-10-17T23:32:00", "kp_index": 0, "estimated_kp": 0.33, "kp": "0P"}, | ||
| 8 | {"time_tag": "2026-10-17T23:33:00", "kp_index": 0, "estimated_kp": 0.67, "kp": "1M"}] | ||
| 9 | """.utf8) | ||
| 10 | |||
| 11 | let forecast = Data(""" | ||
| 12 | [{"time_tag": "2026-10-17T18:00:00", "kp": 2.33, "observed": "observed", "noaa_scale": null}, | ||
| 13 | {"time_tag": "2026-10-18T00:00:00", "kp": 3.67, "observed": "predicted", "noaa_scale": null}, | ||
| 14 | {"time_tag": "2026-10-18T09:00:00", "kp": 5.33, "observed": "predicted", "noaa_scale": "G1"}, | ||
| 15 | {"time_tag": "2026-10-19T21:00:00", "kp": 7.0, "observed": "predicted", "noaa_scale": "G3"}] | ||
| 16 | """.utf8) | ||
| 17 | |||
| 18 | @Test func decodesLatestKpAndNext24hMaximum() throws { | ||
| 19 | let at = try #require(SpaceWeather.parse("2026-10-17T23:34:00")) | ||
| 20 | let w = try SpaceWeather.decode(now: minutes, forecast: forecast, at: at) | ||
| 21 | #expect(w.kpNow == 0.67) | ||
| 22 | #expect(w.observedAt == SpaceWeather.parse("2026-10-17T23:33:00")) | ||
| 23 | #expect(w.kpNext24h == 5.33) | ||
| 24 | } | ||
| 25 | |||
| 26 | @Test func gScale() { | ||
| 27 | #expect(SpaceWeather.gScale(4.67) == nil) | ||
| 28 | #expect(SpaceWeather.gScale(5.33) == 1) | ||
| 29 | #expect(SpaceWeather.gScale(9) == 5) | ||
| 30 | } | ||
Packages/MoonriseKit/Tests/SkyEngineTests/TonightTests.swift added +24
| @@ -0,0 +1,24 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import SkyEngine | ||
| 4 | |||
| 5 | @Test func tonightFromDarkStartsNowAndEndsAtSunrise() throws { | ||
| 6 | // 2026-10-17 02:00 UT is 21:00 CDT on the 16th: already dark in Chicago. | ||
| 7 | let t = try Tonight(at: observed, from: chicago) | ||
| 8 | #expect(t.start == observed) | ||
| 9 | #expect(t.sunset == nil) | ||
| 10 | let sunrise = try #require(t.sunrise) | ||
| 11 | #expect(abs(sunrise.timeIntervalSince(ut(12, 6))) < 120) | ||
| 12 | let saturn = try #require(t.planets.first { $0.body == .saturn }) | ||
| 13 | #expect(saturn.best != nil) | ||
| 14 | #expect(t.planets.first?.best != nil) | ||
| 15 | } | ||
| 16 | |||
| 17 | @Test func tonightFromAfternoonStartsAtSunset() throws { | ||
| 18 | // 2026-10-17 18:00 UT is 13:00 CDT. | ||
| 19 | let afternoon = ut(18, 0) | ||
| 20 | let t = try Tonight(at: afternoon, from: chicago) | ||
| 21 | let sunset = try #require(t.sunset) | ||
| 22 | #expect(abs(sunset.timeIntervalSince(ut(23, 6))) < 120) | ||
| 23 | #expect(t.start == sunset) | ||
| 24 | } | ||