M1: Today sheet !4
9 files changed, +344 −18
Layout: unified · split
App/Sources/SkyModel.swift +20
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import CoreLocation |
| 2 | import Feeds | |
| 2 | 3 | import Foundation |
| 3 | 4 | import Observation |
| 4 | 5 | import SkyEngine |
| @@ -17,6 +18,10 @@ actor Astronomer { | ||
| 17 | 18 | let rs = SkyEngine.riseSet(body, after: date, from: observer) |
| 18 | 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 | 27 | struct BodyDetails: Sendable { |
| @@ -40,6 +45,9 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 40 | 45 | private(set) var hasLocation = false |
| 41 | 46 | private(set) var scene: SkyScene? |
| 42 | 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 | 51 | /// Hours from now shown by the time scrubber. |
| 44 | 52 | var offsetHours = 0.0 { didSet { refresh() } } |
| 45 | 53 | var displayedDate: Date { .now.addingTimeInterval(offsetHours * 3600) } |
| @@ -74,11 +82,23 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 74 | 82 | scene = try? await astronomer.scene(at: date, from: observer) |
| 75 | 83 | lastBuild = clock.now - start |
| 76 | 84 | if let scene { self.scene = scene } |
| 85 | if offsetHours == 0 { tonight = try? await astronomer.tonight(at: date, from: observer) } | |
| 77 | 86 | building = false |
| 78 | 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 | 102 | func details(of body: Body) async -> BodyDetails? { |
| 83 | 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 | 36 | VStack(spacing: 0) { |
| 37 | 37 | header |
| 38 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } | |
| 38 | 39 | Spacer() |
| 39 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.bottom, 10) } | |
| 40 | scrubber | |
| 41 | 40 | } |
| 42 | 41 | } |
| 43 | 42 | } |
| 44 | 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 | 50 | .onChange(of: model.offsetHours) { select(selection) } |
| 46 | 51 | .task { |
| 47 | 52 | model.start() |
| @@ -110,22 +115,6 @@ struct SkyScreen: View { | ||
| 110 | 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 | 118 | @ViewBuilder |
| 130 | 119 | private func card(for object: SkyObject) -> some View { |
| 131 | 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 | 16 | product: SkyEngine |
| 17 | 17 | - package: MoonriseKit |
| 18 | 18 | product: SkyRenderer |
| 19 | - package: MoonriseKit | |
| 20 | product: Feeds | |
| 19 | 21 | entitlements: |
| 20 | 22 | path: Sources/Moonrise.entitlements |
| 21 | 23 | properties: |
Packages/MoonriseKit/Package.swift +3
| @@ -7,6 +7,7 @@ let package = Package( | ||
| 7 | 7 | products: [ |
| 8 | 8 | .library(name: "SkyEngine", targets: ["SkyEngine"]), |
| 9 | 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), |
| 10 | .library(name: "Feeds", targets: ["Feeds"]), | |
| 10 | 11 | ], |
| 11 | 12 | targets: [ |
| 12 | 13 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy |
| @@ -21,7 +22,9 @@ let package = Package( | ||
| 21 | 22 | dependencies: ["SkyEngine"], |
| 22 | 23 | resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json")] |
| 23 | 24 | ), |
| 25 | .target(name: "Feeds"), | |
| 24 | 26 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
| 27 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"]), | |
| 25 | 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 | } | |