Commit b327e0d6e7
Unsigned
Layout: unified · split
App/Sources/SkyScreen.swift +1 −1
| @@ -98,7 +98,7 @@ struct SkyScreen: View { | ||
| 98 | 98 | .preferredColorScheme(.dark) |
| 99 | 99 | } |
| 100 | 100 | .sheet(isPresented: $showTours) { |
| 101 | TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark) | |
| 101 | TourPicker(tonight: model.tonight) { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark) | |
| 102 | 102 | } |
| 103 | 103 | .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } } |
| 104 | 104 | .sheet(isPresented: $showYear) { |
App/Sources/TodaySheet.swift +4 −2
| @@ -12,6 +12,7 @@ struct TodaySheet: View { | ||
| 12 | 12 | ScrollView { |
| 13 | 13 | VStack(alignment: .leading, spacing: 0) { |
| 14 | 14 | if let t = model.tonight { |
| 15 | section("Tonight") { Text(t.moonAndDarkness()).font(.subheadline.monospacedDigit()) } | |
| 15 | 16 | section("Moon") { moon(t) } |
| 16 | 17 | section("Planets tonight") { planets(t) } |
| 17 | 18 | section("Sun") { sun(t) } |
| @@ -95,7 +96,8 @@ struct TodaySheet: View { | ||
| 95 | 96 | .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1)) |
| 96 | 97 | } |
| 97 | 98 | } |
| 98 | Text(p.rise.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + String(format: " · rises %@, highest %.0f°, sets %@", compass(p.riseAzimuth), p.maxAltitude, compass(p.setAzimuth))) | |
| 99 | Text(p.rise.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + String(format: " · rises %@, highest %.0f°, sets %@", compass(p.riseAzimuth), p.maxAltitude, compass(p.setAzimuth)) | |
| 100 | + (p.moonNote(from: model.observer).map { " · \($0)" } ?? "")) | |
| 99 | 101 | .font(.footnote.monospacedDigit()).dimmed(0.8) |
| 100 | 102 | } |
| 101 | 103 | .accessibilityElement(children: .combine) |
| @@ -107,7 +109,7 @@ struct TodaySheet: View { | ||
| 107 | 109 | if !others.isEmpty { |
| 108 | 110 | Text("Also visible tonight").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 6) |
| 109 | 111 | ForEach(others) { p in |
| 110 | Text("\(p.displayName) · \(p.rise.formatted(date: .omitted, time: .shortened)), highest \(Int(p.maxAltitude.rounded()))°") | |
| 112 | Text("\(p.displayName) · \(p.rise.formatted(date: .omitted, time: .shortened)), highest \(Int(p.maxAltitude.rounded()))°" + (p.moonNote(from: model.observer).map { " · \($0)" } ?? "")) | |
| 111 | 113 | .font(.footnote.monospacedDigit()).dimmed(0.8) |
| 112 | 114 | } |
| 113 | 115 | } |
App/Sources/TourViews.swift +7
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import Library |
| 2 | import SkyEngine | |
| 2 | 3 | import SwiftUI |
| 3 | 4 | |
| 4 | 5 | /// The tours, starting with the one for this month. |
| 5 | 6 | struct TourPicker: View { |
| 7 | var tonight: Tonight? | |
| 6 | 8 | var onPick: (Tour) -> Void |
| 7 | 9 | @Environment(\.dismiss) private var dismiss |
| 8 | 10 | |
| @@ -19,6 +21,11 @@ struct TourPicker: View { | ||
| 19 | 21 | Text("A walk around the evening sky, one sight at a time. The view turns to each stop and marks what to look for. Best outside after dark, but they work anywhere.") |
| 20 | 22 | .font(.subheadline).foregroundStyle(ink.opacity(0.75)) |
| 21 | 23 | .padding(.bottom, 20) |
| 24 | if let tonight, tonight.moonlit { | |
| 25 | Text("Tonight: \(tonight.moonAndDarkness()) Fainter stars are harder to see while the Moon is up.") | |
| 26 | .font(.footnote).foregroundStyle(ink.opacity(0.75)) | |
| 27 | .padding(.bottom, 20) | |
| 28 | } | |
| 22 | 29 | ForEach(Tour.ordered(month: month)) { tour in card(tour) } |
| 23 | 30 | } |
| 24 | 31 | .padding(20) |
Packages/MoonriseKit/Sources/SkyEngine/Darkness.swift added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import CAstronomy | |
| 2 | import Foundation | |
| 3 | ||
| 4 | extension SkyEngine { | |
| 5 | /// Moon illumination from which moonlight is taken to hide faint satellites and stars. | |
| 6 | public static let moonlightLimit = 0.25 | |
| 7 | ||
| 8 | /// Next time within `limitDays` after `date` that the body's centre crosses `altitude` degrees going up | |
| 9 | /// (`rising`) or down, without refraction (the convention for twilight); nil if it does not. | |
| 10 | public static func searchAltitude(_ body: Body, altitude: Double, rising: Bool, after date: Date, from observer: Observer, limitDays: Double = 1) -> Date? { | |
| 11 | lock.lock() | |
| 12 | defer { lock.unlock() } | |
| 13 | let r = Astronomy_SearchAltitude(body.astro, observer.astro, rising ? DIRECTION_RISE : DIRECTION_SET, time(date), limitDays, altitude) | |
| 14 | return r.status == ASTRO_SUCCESS ? Self.date(r.time) : nil | |
| 15 | } | |
| 16 | ||
| 17 | /// Astronomical darkness (Sun below −18°) between `start` and `end`, clipped to them; nil if it never gets that dark. | |
| 18 | public static func darkness(from start: Date, to end: Date, observer: Observer) throws -> DateInterval? { | |
| 19 | let days = end.timeIntervalSince(start) / 86_400 | |
| 20 | let dusk = try horizontal(.sun, at: start, from: observer).altitude < -18 | |
| 21 | ? start | |
| 22 | : searchAltitude(.sun, altitude: -18, rising: false, after: start, from: observer, limitDays: days) | |
| 23 | guard let dusk, dusk < end else { return nil } | |
| 24 | let dawn = searchAltitude(.sun, altitude: -18, rising: true, after: dusk, from: observer, limitDays: days).map { min($0, end) } ?? end | |
| 25 | return DateInterval(start: dusk, end: dawn) | |
| 26 | } | |
| 27 | ||
| 28 | /// When the Moon is above the horizon between `start` and `end`, clipped to them. | |
| 29 | public static func moonUp(from start: Date, to end: Date, observer: Observer) throws -> [DateInterval] { | |
| 30 | var out: [DateInterval] = [] | |
| 31 | var t = start | |
| 32 | while t < end { | |
| 33 | let days = end.timeIntervalSince(t) / 86_400 | |
| 34 | let next = riseSet(.moon, after: t, from: observer, limitDays: days) | |
| 35 | if let set = next.set, next.rise.map({ set < $0 }) ?? true { | |
| 36 | out.append(DateInterval(start: t, end: set)) | |
| 37 | t = set.addingTimeInterval(60) | |
| 38 | } else if let rise = next.rise { | |
| 39 | let set = riseSet(.moon, after: rise, from: observer, limitDays: end.timeIntervalSince(rise) / 86_400).set ?? end | |
| 40 | out.append(DateInterval(start: rise, end: set)) | |
| 41 | t = set.addingTimeInterval(60) | |
| 42 | } else { | |
| 43 | if try horizontal(.moon, at: t, from: observer, refraction: true).altitude > 0 { out.append(DateInterval(start: t, end: end)) } | |
| 44 | break | |
| 45 | } | |
| 46 | } | |
| 47 | return out | |
| 48 | } | |
| 49 | ||
| 50 | /// "Dark 20:05–05:42. Moon 62% lit, sets 23:10." The Moon's rise and set are those during darkness, | |
| 51 | /// or during `night` where it never gets fully dark. | |
| 52 | public static func moonAndDarkness(night: DateInterval, dark: DateInterval?, moonUp: [DateInterval], moonIllumination: Double, | |
| 53 | time: Date.FormatStyle = .init(date: .omitted, time: .shortened)) -> String { | |
| 54 | let window = dark ?? night | |
| 55 | let up = moonUp.compactMap { $0.intersection(with: window) }.filter { $0.duration > 0 } | |
| 56 | let first = dark.map { "Dark \($0.start.formatted(time))–\($0.end.formatted(time))." } ?? "The sky does not get fully dark." | |
| 57 | let lit = "Moon \(Int((moonIllumination * 100).rounded()))% lit" | |
| 58 | let moon: String | |
| 59 | if up.isEmpty { | |
| 60 | moon = "Moon down throughout." | |
| 61 | } else if up.count == 1, up[0] == window { | |
| 62 | moon = "\(lit), up throughout." | |
| 63 | } else { | |
| 64 | var times: [String] = [] | |
| 65 | for i in up { | |
| 66 | if i.start > window.start { times.append("rises \(i.start.formatted(time))") } | |
| 67 | if i.end < window.end { times.append("sets \(i.end.formatted(time))") } | |
| 68 | } | |
| 69 | moon = "\(lit), \(times.joined(separator: ", "))." | |
| 70 | } | |
| 71 | return "\(first) \(moon)" | |
| 72 | } | |
| 73 | } | |
Packages/MoonriseKit/Sources/SkyEngine/Events.swift +7 −14
| @@ -136,12 +136,7 @@ extension SkyEngine { | ||
| 136 | 136 | } |
| 137 | 137 | } |
| 138 | 138 | |
| 139 | // Meteor showers, with the Moon on the observer's night of the peak | |
| 140 | func altitude(_ body: astro_body_t, _ d: Date) -> Double { | |
| 141 | var t = time(d) | |
| 142 | let eq = Astronomy_Equator(body, &t, observer.astro, EQUATOR_OF_DATE, ABERRATION) | |
| 143 | return Astronomy_Horizon(&t, observer.astro, eq.ra, eq.dec, REFRACTION_NORMAL).altitude | |
| 144 | } | |
| 139 | // Meteor showers, with darkness and the Moon on the observer's night of the peak | |
| 145 | 140 | var utc = Calendar(identifier: .gregorian) |
| 146 | 141 | utc.timeZone = TimeZone(identifier: "UTC")! |
| 147 | 142 | for y in y0...(y0 + 1) { |
| @@ -150,14 +145,12 @@ extension SkyEngine { | ||
| 150 | 145 | // Local solar midnight after the evening of the peak date. |
| 151 | 146 | let midnight = d.addingTimeInterval((12 - observer.longitude / 15) * 3600) |
| 152 | 147 | let lit = Astronomy_Illumination(BODY_MOON, time(midnight)).phase_fraction |
| 153 | // Dark hours: Sun below -18°, or just midnight where it never gets that dark. | |
| 154 | var dark = stride(from: -12.0, through: 12.0, by: 0.25).map { midnight.addingTimeInterval($0 * 3600) }.filter { altitude(BODY_SUN, $0) < -18 } | |
| 155 | if dark.isEmpty { dark = [midnight] } | |
| 156 | let up = dark.map { altitude(BODY_MOON, $0) > 0 } | |
| 157 | let moon = !up.contains(true) ? "below the horizon during the dark hours" | |
| 158 | : !up.contains(false) ? "up all through the dark hours" | |
| 159 | : up[0] ? "sets during the dark hours" : "rises during the dark hours" | |
| 160 | out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit, %@.", m.zhr, lit * 100, moon), visibleHere: nil)) | |
| 148 | let noon = midnight.addingTimeInterval(-12 * 3600) | |
| 149 | let sunset = riseSet(.sun, after: noon, from: observer).set ?? noon | |
| 150 | let night = DateInterval(start: sunset, end: riseSet(.sun, after: sunset, from: observer).rise ?? sunset.addingTimeInterval(12 * 3600)) | |
| 151 | let moon = moonAndDarkness(night: night, dark: try? darkness(from: night.start, to: night.end, observer: observer), | |
| 152 | moonUp: (try? moonUp(from: night.start, to: night.end, observer: observer)) ?? [], moonIllumination: lit) | |
| 153 | out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: "Up to about \(m.zhr) meteors an hour under dark skies. \(moon)", visibleHere: nil)) | |
| 161 | 154 | } |
| 162 | 155 | } |
| 163 | 156 | |
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +9
| @@ -31,6 +31,15 @@ public struct SatellitePass: Sendable, Identifiable, Equatable { | ||
| 31 | 31 | guard set > now, rise.timeIntervalSince(now) <= 86_400 else { return nil } |
| 32 | 32 | return max(now, rise.addingTimeInterval(-600)) |
| 33 | 33 | } |
| 34 | ||
| 35 | /// "Moon up, 80% lit" for a visible pass with the Moon above the horizon at its peak and lit past `moonlightLimit`, when | |
| 36 | /// moonlight can hide a faint satellite. Nil for the ISS and Tiangong, which are bright enough that it does not matter. | |
| 37 | public func moonNote(from observer: Observer) -> String? { | |
| 38 | guard visible, name != "ISS (ZARYA)", name != "CSS (TIANHE)", | |
| 39 | let moon = try? SkyEngine.horizontal(.moon, at: peak, from: observer, refraction: true), moon.altitude > 0, | |
| 40 | let lit = try? SkyEngine.illumination(.moon, at: peak).phaseFraction, lit >= SkyEngine.moonlightLimit else { return nil } | |
| 41 | return "Moon up, \(Int((lit * 100).rounded()))% lit" | |
| 42 | } | |
| 34 | 43 | } |
| 35 | 44 | |
| 36 | 45 | extension OrbitalElements { |
Packages/MoonriseKit/Sources/SkyEngine/Tonight.swift +21
| @@ -16,6 +16,10 @@ public struct Tonight: Sendable { | ||
| 16 | 16 | public var end: Date |
| 17 | 17 | public var sunset: Date? |
| 18 | 18 | public var sunrise: Date? |
| 19 | /// Astronomical dusk to dawn (Sun below −18°) within start...end; nil where it never gets that dark. | |
| 20 | public var dark: DateInterval? | |
| 21 | /// When the Moon is above the horizon within start...end. | |
| 22 | public var moonUp: [DateInterval] | |
| 19 | 23 | public var moonIllumination: Double |
| 20 | 24 | public var moonPhase: Double |
| 21 | 25 | public var nextQuarter: (quarter: SkyEngine.MoonQuarter, date: Date) |
| @@ -34,6 +38,8 @@ public struct Tonight: Sendable { | ||
| 34 | 38 | } |
| 35 | 39 | sunrise = SkyEngine.riseSet(.sun, after: start, from: observer).rise |
| 36 | 40 | end = sunrise ?? start.addingTimeInterval(12 * 3600) |
| 41 | dark = try SkyEngine.darkness(from: start, to: end, observer: observer) | |
| 42 | moonUp = try SkyEngine.moonUp(from: start, to: end, observer: observer) | |
| 37 | 43 | |
| 38 | 44 | moonIllumination = try SkyEngine.illumination(.moon, at: date).phaseFraction |
| 39 | 45 | moonPhase = try SkyEngine.moonPhase(at: date) |
| @@ -58,4 +64,19 @@ public struct Tonight: Sendable { | ||
| 58 | 64 | } |
| 59 | 65 | .sorted { ($0.best != nil ? 0 : 1, $0.magnitude) < ($1.best != nil ? 0 : 1, $1.magnitude) } |
| 60 | 66 | } |
| 67 | ||
| 68 | /// Moonrise and moonset during the night, if any. | |
| 69 | public var moonrise: Date? { moonUp.first { $0.start > start }?.start } | |
| 70 | public var moonset: Date? { moonUp.first { $0.end < end }?.end } | |
| 71 | ||
| 72 | /// Whether the Moon is up during darkness (or the night, where it never gets fully dark) and lit enough to hide faint objects. | |
| 73 | public var moonlit: Bool { | |
| 74 | let window = dark ?? DateInterval(start: start, end: end) | |
| 75 | return moonIllumination >= SkyEngine.moonlightLimit && moonUp.contains { ($0.intersection(with: window)?.duration ?? 0) > 0 } | |
| 76 | } | |
| 77 | ||
| 78 | /// "Dark 20:05–05:42. Moon 62% lit, sets 23:10." | |
| 79 | public func moonAndDarkness(time: Date.FormatStyle = .init(date: .omitted, time: .shortened)) -> String { | |
| 80 | SkyEngine.moonAndDarkness(night: DateInterval(start: start, end: end), dark: dark, moonUp: moonUp, moonIllumination: moonIllumination, time: time) | |
| 81 | } | |
| 61 | 82 | } |
Packages/MoonriseKit/Tests/SkyEngineTests/DarknessTests.swift added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | private let central = TimeZone(identifier: "America/Chicago")! | |
| 6 | private let hm = Date.FormatStyle(date: .omitted, time: .shortened, locale: Locale(identifier: "en_GB"), timeZone: central) | |
| 7 | ||
| 8 | @Test func astronomicalDuskAndDawnInChicago() throws { | |
| 9 | // NOAA solar position algorithm: Sun at −18° at 00:38:30 and 10:33:40 UT on 18 October 2026. | |
| 10 | let t = try Tonight(at: ut(18, 0), from: chicago) | |
| 11 | let dark = try #require(t.dark) | |
| 12 | #expect(abs(dark.start.timeIntervalSince(ut(24, 38) + 30)) < 60) | |
| 13 | #expect(abs(dark.end.timeIntervalSince(ut(34, 33) + 40)) < 60) | |
| 14 | } | |
| 15 | ||
| 16 | @Test func alreadyDarkStartsNow() throws { | |
| 17 | // 21:00 CDT; NOAA has the Sun back at −18° at 10:32:30 UT. | |
| 18 | let t = try Tonight(at: observed, from: chicago) | |
| 19 | let dark = try #require(t.dark) | |
| 20 | #expect(dark.start == observed) | |
| 21 | #expect(abs(dark.end.timeIntervalSince(ut(10, 32) + 30)) < 60) | |
| 22 | } | |
| 23 | ||
| 24 | @Test func noDarknessInTromsoInJune() throws { | |
| 25 | let tromso = Observer(latitude: 69.65, longitude: 18.96) | |
| 26 | #expect(try SkyEngine.darkness(from: utc("2026-06-21T12:00:00Z"), to: utc("2026-06-22T12:00:00Z"), observer: tromso) == nil) | |
| 27 | } | |
| 28 | ||
| 29 | @Test func waxingMoonSetsInTheEvening() throws { | |
| 30 | // 35% lit and up at 21:00 CDT on 16 October 2026; JPL Horizons has it setting at 02:49 UT. | |
| 31 | let t = try Tonight(at: observed, from: chicago) | |
| 32 | #expect(t.moonUp.count == 1) | |
| 33 | #expect(t.moonUp[0].start == t.start) | |
| 34 | #expect(t.moonrise == nil) | |
| 35 | let set = try #require(t.moonset) | |
| 36 | #expect(abs(set.timeIntervalSince(ut(2, 49))) < 90) | |
| 37 | #expect(t.moonAndDarkness(time: hm) == "Dark 21:00–5:32. Moon 35% lit, sets 21:48.") | |
| 38 | } | |
| 39 | ||
| 40 | @Test func moonUpAlternatesWithRiseAndSet() throws { | |
| 41 | let start = ut(18, 0) | |
| 42 | let up = try SkyEngine.moonUp(from: start, to: start + 3 * 86_400, observer: chicago) | |
| 43 | #expect(up.count >= 3) | |
| 44 | for (a, b) in zip(up, up.dropFirst()) { | |
| 45 | #expect(b.start > a.end) | |
| 46 | #expect(try SkyEngine.horizontal(.moon, at: a.end + b.start.timeIntervalSince(a.end) / 2, from: chicago).altitude < 0) | |
| 47 | } | |
| 48 | for i in up { | |
| 49 | #expect(try SkyEngine.horizontal(.moon, at: i.start + i.duration / 2, from: chicago).altitude > 0) | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | @Test func moonAndDarknessText() { | |
| 54 | let base = ut(24, 0) | |
| 55 | func at(_ h: Double) -> Date { base + h * 3600 } | |
| 56 | let night = DateInterval(start: at(-1), end: at(11)) | |
| 57 | let dark = DateInterval(start: at(0.5), end: at(10.5)) | |
| 58 | func text(_ up: [DateInterval], dark: DateInterval? = dark) -> String { | |
| 59 | SkyEngine.moonAndDarkness(night: night, dark: dark, moonUp: up, moonIllumination: 0.624, time: hm) | |
| 60 | } | |
| 61 | #expect(text([]) == "Dark 19:30–5:30. Moon down throughout.") | |
| 62 | #expect(text([DateInterval(start: at(-1), end: at(0.2))]) == "Dark 19:30–5:30. Moon down throughout.") | |
| 63 | #expect(text([night]) == "Dark 19:30–5:30. Moon 62% lit, up throughout.") | |
| 64 | #expect(text([DateInterval(start: at(-1), end: at(4))]) == "Dark 19:30–5:30. Moon 62% lit, sets 23:00.") | |
| 65 | #expect(text([DateInterval(start: at(8), end: at(11))]) == "Dark 19:30–5:30. Moon 62% lit, rises 3:00.") | |
| 66 | #expect(text([DateInterval(start: at(-1), end: at(2)), DateInterval(start: at(9), end: at(11))]) | |
| 67 | == "Dark 19:30–5:30. Moon 62% lit, sets 21:00, rises 4:00.") | |
| 68 | #expect(text([DateInterval(start: at(-1), end: at(0))], dark: nil) == "The sky does not get fully dark. Moon 62% lit, sets 19:00.") | |
| 69 | } | |
| 70 | ||
| 71 | @Test func moonNoteOnFaintPasses() { | |
| 72 | func pass(_ name: String, peak: Date, visible: Bool = true) -> SatellitePass { | |
| 73 | SatellitePass(name: name, rise: peak - 180, set: peak + 180, peak: peak, maxAltitude: 60, riseAzimuth: 240, setAzimuth: 60, visible: visible) | |
| 74 | } | |
| 75 | // Moon 35% lit and up at 02:00 UT on 17 October 2026, set by 04:00. | |
| 76 | #expect(pass("HST", peak: observed).moonNote(from: chicago) == "Moon up, 35% lit") | |
| 77 | #expect(pass("SL-8 R/B", peak: observed).moonNote(from: chicago) == "Moon up, 35% lit") | |
| 78 | #expect(pass("HST", peak: ut(4, 0)).moonNote(from: chicago) == nil) | |
| 79 | #expect(pass("HST", peak: observed, visible: false).moonNote(from: chicago) == nil) | |
| 80 | #expect(pass("ISS (ZARYA)", peak: observed).moonNote(from: chicago) == nil) | |
| 81 | #expect(pass("CSS (TIANHE)", peak: observed).moonNote(from: chicago) == nil) | |
| 82 | // 2026-10-12 23:30 UT: 6% lit crescent, 3° up after sunset. | |
| 83 | #expect(pass("HST", peak: utc("2026-10-12T23:30:00Z")).moonNote(from: chicago) == nil) | |
| 84 | } | |
| 85 | ||
| 86 | @Test func moonlitWhenUpAndLitDuringDarkness() throws { | |
| 87 | // 35% lit and up until 21:48 CDT. | |
| 88 | #expect(try Tonight(at: observed, from: chicago).moonlit) | |
| 89 | // New moon on 12 August 2026. | |
| 90 | #expect(try !Tonight(at: utc("2026-08-12T18:00:00Z"), from: chicago).moonlit) | |
| 91 | } | |
Packages/MoonriseKit/Tests/SkyEngineTests/EventsTests.swift +4 −3
| @@ -38,9 +38,10 @@ func utc(_ s: String) -> Date { try! Date(s, strategy: .iso8601) } | ||
| 38 | 38 | let events = SkyEngine.events(from: utc("2026-08-01T00:00:00Z"), days: 180, observer: chicago) |
| 39 | 39 | func detail(_ name: String) throws -> String { try #require(events.first { $0.title == "\(name) peak" }).detail } |
| 40 | 40 | // New moon on 12 August 2026. |
| 41 | #expect(try detail("Perseids").hasSuffix("Moon 0% lit, below the horizon during the dark hours.")) | |
| 41 | #expect(try detail("Perseids").hasSuffix("Moon down throughout.")) | |
| 42 | 42 | // 76% at 12:00 UTC on the 21st; 82% at Chicago midnight, setting about 03:00 CDT. |
| 43 | #expect(try detail("Orionids").hasSuffix("Moon 82% lit, sets during the dark hours.")) | |
| 43 | #expect(try detail("Orionids").contains("Moon 82% lit, sets ")) | |
| 44 | 44 | // 16% at 12:00 UTC on the 3rd; 11% at Chicago midnight, rising about 05:00 CST before dawn. |
| 45 | #expect(try detail("Quadrantids").hasSuffix("Moon 11% lit, rises during the dark hours.")) | |
| 45 | #expect(try detail("Quadrantids").contains("Moon 11% lit, rises ")) | |
| 46 | #expect(try detail("Quadrantids").contains("Dark ")) | |
| 46 | 47 | } |