Packages/MoonriseKit/Sources/SkyEngine/Darkness.swift
73 lines · 4049 bytes
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}