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

Commit b327e0d6e7

b327e0d6e7ab67511c5bade3f3c3a93834f6e9ca

parent: 6c4b28384d

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 03:11 UTC

Observing conditions, phase 1: Moon and darkness (!65)

On-device only; no network data, no ratings.

- `SkyEngine.searchAltitude` (wraps `Astronomy_SearchAltitude`), `darkness(from:to:)` (Sun below −18°), `moonUp(from:to:)` and `moonAndDarkness(...)` in `SkyEngine/Darkness.swift`.
- `Tonight`: `dark`, `moonUp`, `moonrise`, `moonset`, `moonlit`, `moonAndDarkness()`.
- Today sheet: new first section "Tonight", e.g. "Dark 22:09–05:35. Moon 64% lit, sets 00:56."
- Meteor shower peaks: the Moon note now uses the same text for the local night of the peak, with times ("Dark 19:32–05:37. Moon 82% lit, sets 03:08.") instead of "sets during the dark hours".
- Pass rows: "· Moon up, 35% lit" on visible passes when the Moon is up at the peak and at least 25% lit. Not shown for the ISS or Tiangong.
- Tour picker: one line with tonight's darkness and Moon when the Moon is ≥25% lit and up during darkness.

Tests: astronomical dusk/dawn for Chicago against the NOAA solar algorithm (within 1 min), moonset against the existing JPL Horizons figure, Tromsø in June has no darkness, Moon-up intervals, text cases, pass note cases. `swift test`, server `npm test` and the accessibility audit pass. Audit: 67 issues on main and 67 on this branch, with the same per-screen counts (Library 14, Saturn page 13, News & launches 23, Settings 3, Sky 4, Sun 3, System 7). A first run on main gave 65; the two extra Library "Text clipped" items vary between runs on main as well.

Not verified:
- The audit does not visit the Today sheet, tour picker or Events screen. The Today line and tour picker line were checked by eye on the simulator (Chicago); I did not see a pass row with the Moon note on screen, because the Moon had set before tonight's visible passes.
- Meteor notes do not yet take the radiant's altitude into account (design phase 1 mentions "radiant up"). `MeteorShower` has no radiant coordinates.
- When it is already dark, the line starts at the current time ("Dark 22:09–…").
- High latitudes beyond the Tromsø no-darkness case, and the Watch and widgets, which now compute the extra fields in `Tonight` without showing them.

Layout: unified · split

App/Sources/SkyScreen.swift +1 −1
@@ -98,7 +98,7 @@ struct SkyScreen: View {
9898 .preferredColorScheme(.dark)
9999 }
100100 .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)
102102 }
103103 .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } }
104104 .sheet(isPresented: $showYear) {
App/Sources/TodaySheet.swift +4 −2
@@ -12,6 +12,7 @@ struct TodaySheet: View {
1212 ScrollView {
1313 VStack(alignment: .leading, spacing: 0) {
1414 if let t = model.tonight {
15 section("Tonight") { Text(t.moonAndDarkness()).font(.subheadline.monospacedDigit()) }
1516 section("Moon") { moon(t) }
1617 section("Planets tonight") { planets(t) }
1718 section("Sun") { sun(t) }
@@ -95,7 +96,8 @@ struct TodaySheet: View {
9596 .overlay(RoundedRectangle(cornerRadius: 3).stroke(lineWidth: 1))
9697 }
9798 }
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)" } ?? ""))
99101 .font(.footnote.monospacedDigit()).dimmed(0.8)
100102 }
101103 .accessibilityElement(children: .combine)
@@ -107,7 +109,7 @@ struct TodaySheet: View {
107109 if !others.isEmpty {
108110 Text("Also visible tonight").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 6)
109111 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)" } ?? ""))
111113 .font(.footnote.monospacedDigit()).dimmed(0.8)
112114 }
113115 }
App/Sources/TourViews.swift +7
@@ -1,8 +1,10 @@
11import Library
2import SkyEngine
23import SwiftUI
34
45/// The tours, starting with the one for this month.
56struct TourPicker: View {
7 var tonight: Tonight?
68 var onPick: (Tour) -> Void
79 @Environment(\.dismiss) private var dismiss
810
@@ -19,6 +21,11 @@ struct TourPicker: View {
1921 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.")
2022 .font(.subheadline).foregroundStyle(ink.opacity(0.75))
2123 .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 }
2229 ForEach(Tour.ordered(month: month)) { tour in card(tour) }
2330 }
2431 .padding(20)
Packages/MoonriseKit/Sources/SkyEngine/Darkness.swift added +73
@@ -0,0 +1,73 @@
1import CAstronomy
2import Foundation
3
4extension 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 {
136136 }
137137 }
138138
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
145140 var utc = Calendar(identifier: .gregorian)
146141 utc.timeZone = TimeZone(identifier: "UTC")!
147142 for y in y0...(y0 + 1) {
@@ -150,14 +145,12 @@ extension SkyEngine {
150145 // Local solar midnight after the evening of the peak date.
151146 let midnight = d.addingTimeInterval((12 - observer.longitude / 15) * 3600)
152147 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))
161154 }
162155 }
163156
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +9
@@ -31,6 +31,15 @@ public struct SatellitePass: Sendable, Identifiable, Equatable {
3131 guard set > now, rise.timeIntervalSince(now) <= 86_400 else { return nil }
3232 return max(now, rise.addingTimeInterval(-600))
3333 }
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 }
3443}
3544
3645extension OrbitalElements {
Packages/MoonriseKit/Sources/SkyEngine/Tonight.swift +21
@@ -16,6 +16,10 @@ public struct Tonight: Sendable {
1616 public var end: Date
1717 public var sunset: Date?
1818 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]
1923 public var moonIllumination: Double
2024 public var moonPhase: Double
2125 public var nextQuarter: (quarter: SkyEngine.MoonQuarter, date: Date)
@@ -34,6 +38,8 @@ public struct Tonight: Sendable {
3438 }
3539 sunrise = SkyEngine.riseSet(.sun, after: start, from: observer).rise
3640 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)
3743
3844 moonIllumination = try SkyEngine.illumination(.moon, at: date).phaseFraction
3945 moonPhase = try SkyEngine.moonPhase(at: date)
@@ -58,4 +64,19 @@ public struct Tonight: Sendable {
5864 }
5965 .sorted { ($0.best != nil ? 0 : 1, $0.magnitude) < ($1.best != nil ? 0 : 1, $1.magnitude) }
6066 }
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 }
6182}
Packages/MoonriseKit/Tests/SkyEngineTests/DarknessTests.swift added +91
@@ -0,0 +1,91 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private let central = TimeZone(identifier: "America/Chicago")!
6private 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) }
3838 let events = SkyEngine.events(from: utc("2026-08-01T00:00:00Z"), days: 180, observer: chicago)
3939 func detail(_ name: String) throws -> String { try #require(events.first { $0.title == "\(name) peak" }).detail }
4040 // 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."))
4242 // 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 "))
4444 // 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 "))
4647}