diff --git a/Solar/Solar.swift b/Solar/Solar.swift index 97fa9ac..aad894c 100644 --- a/Solar/Solar.swift +++ b/Solar/Solar.swift @@ -44,17 +44,43 @@ public struct Solar { public fileprivate(set) var astronomicalSunrise: Date? public fileprivate(set) var astronomicalSunset: Date? + /// Whether the location is in daytime at `date`: the sun's elevation at that + /// instant is above the official zenith. Because this asks about the instant + /// directly, polar day, polar night, their transition days, and daylight + /// spanning UTC midnight need no special cases. The published events are + /// bisected from this same predicate, so isDaytime becomes true exactly at + /// `sunrise` and false exactly at `sunset`. + public var isDaytime: Bool { + return sunIsUp(atEpoch: date.timeIntervalSince1970, above: .official) + } + + /// Whether the location specified by the `latitude` and `longitude` is in nighttime on `date` + public var isNighttime: Bool { + return !isDaytime + } + + /// Trigonometry of the immutable latitude, shared by every predicate evaluation. + fileprivate let sinLatitude: Double + fileprivate let cosLatitude: Double + + /// The day-of-year mapping for `date`'s year and its neighbours. + fileprivate let yearMap: YearMap + // MARK: Init - + public init?(for date: Date = Date(), coordinate: CLLocationCoordinate2D) { self.date = date - - guard CLLocationCoordinate2DIsValid(coordinate) else { + + guard CLLocationCoordinate2DIsValid(coordinate), let yearMap = YearMap(containing: date) else { return nil } - + self.coordinate = coordinate - + self.yearMap = yearMap + let latitude = coordinate.latitude.degreesToRadians + self.sinLatitude = sin(latitude) + self.cosLatitude = cos(latitude) + // Fill this Solar object with relevant data calculate() } @@ -64,23 +90,33 @@ public struct Solar { /// Sets all of the Solar object's sunrise / sunset variables, if possible. /// - Note: Can return `nil` objects if sunrise / sunset does not occur on that day. public mutating func calculate() { - sunrise = calculate(.sunrise, for: date, and: .official) - sunset = calculate(.sunset, for: date, and: .official) - civilSunrise = calculate(.sunrise, for: date, and: .civil) - civilSunset = calculate(.sunset, for: date, and: .civil) - nauticalSunrise = calculate(.sunrise, for: date, and: .nautical) - nauticalSunset = calculate(.sunset, for: date, and: .nautical) - astronomicalSunrise = calculate(.sunrise, for: date, and: .astronimical) - astronomicalSunset = calculate(.sunset, for: date, and: .astronimical) + // Anchor on the solar noon nearest the middle of `date`'s UTC day. Each event + // is then the exact whole second the elevation predicate flips, found by + // bisecting between solar midnight and solar noon; the transit itself is + // zenith-independent, so one anchor serves all eight events. + let dayOrdinal = floor(yearMap.t(forEpoch: date.timeIntervalSince1970)) + let transitT = solarTransitT(near: dayOrdinal + (12 - lngHour) / 24) + let transitEpoch = floor(yearMap.epoch(forT: transitT)) + + (sunrise, sunset) = events(at: .official, transitEpoch: transitEpoch) + (civilSunrise, civilSunset) = events(at: .civil, transitEpoch: transitEpoch) + (nauticalSunrise, nauticalSunset) = events(at: .nautical, transitEpoch: transitEpoch) + (astronomicalSunrise, astronomicalSunset) = events(at: .astronimical, transitEpoch: transitEpoch) } // MARK: - Private functions + fileprivate static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + fileprivate enum SunriseSunset { case sunrise case sunset } - + /// Used for generating several of the possible sunrise / sunset times fileprivate enum Zenith: Double { case official = 90.83 @@ -89,21 +125,16 @@ public struct Solar { case astronimical = 108 } - fileprivate func calculate(_ sunriseSunset: SunriseSunset, for date: Date, and zenith: Zenith) -> Date? { - guard let utcTimezone = TimeZone(identifier: "UTC") else { return nil } - - // Get the day of the year - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = utcTimezone - guard let dayInt = calendar.ordinality(of: .day, in: .year, for: date) else { return nil } - let day = Double(dayInt) - - // Convert longitude to hour value and calculate an approx. time - let lngHour = coordinate.longitude / 15 - - let hourTime: Double = sunriseSunset == .sunrise ? 6 : 18 - let t = day + ((hourTime - lngHour) / 24) - + /// The sun's position for an approximate time `t` (days since the start of the + /// year, including the fraction of the day), per the USNO Almanac for Computers + /// sunrise/sunset algorithm. + fileprivate struct SunPosition { + let rightAscension: Double // hours + let sinDeclination: Double + let cosDeclination: Double + } + + fileprivate static func sunPosition(forT t: Double) -> SunPosition { // Calculate the suns mean anomaly let M = (0.9856 * t) - 3.289 @@ -131,62 +162,133 @@ public struct Solar { // Calculate Sun's declination let sinDec = 0.39782 * sin(L.degreesToRadians) - let cosDec = cos(asin(sinDec)) - - // Calculate the Sun's local hour angle - let cosH = (cos(zenith.rawValue.degreesToRadians) - (sinDec * sin(coordinate.latitude.degreesToRadians))) / (cosDec * cos(coordinate.latitude.degreesToRadians)) - - // No sunrise - guard cosH < 1 else { - return nil + return SunPosition(rightAscension: RA, + sinDeclination: sinDec, + cosDeclination: cos(asin(sinDec))) + } + + /// Longitude expressed in hours of Earth rotation (15° per hour). + fileprivate var lngHour: Double { + return coordinate.longitude / 15 + } + + /// The almanac's clock relation, T = H + RA - siderealDrift * t - clockOffset, + /// links a time of day to the sun's hour angle. `sunIsUp` and `solarTransitT` + /// both invert it, so the constants are shared to keep them exact inverses. + fileprivate static let siderealDrift = 0.06571 + fileprivate static let clockOffset = 6.622 + + fileprivate static let secondsPerDay: TimeInterval = 86400 + + /// Maps instants to the fractional day-of-year `t` the position model is + /// parameterized on (1.0 = midnight starting 1 January), against the year that + /// contains each instant — so an event published in an adjacent year agrees + /// exactly with a fresh Solar built at it. + fileprivate struct YearMap { + let previous: TimeInterval + let current: TimeInterval + let next: TimeInterval + + init?(containing date: Date) { + let year = Solar.utcCalendar.dateComponents([.year], from: date) + guard + let currentStart = Solar.utcCalendar.date(from: year), + let previousStart = Solar.utcCalendar.date(byAdding: .year, value: -1, to: currentStart), + let nextStart = Solar.utcCalendar.date(byAdding: .year, value: 1, to: currentStart) + else { + return nil + } + previous = previousStart.timeIntervalSince1970 + current = currentStart.timeIntervalSince1970 + next = nextStart.timeIntervalSince1970 } - - // No sunset - guard cosH > -1 else { + + /// Whole seconds only, matching Date equality on the published events. + func t(forEpoch epoch: TimeInterval) -> Double { + let second = floor(epoch) + let yearStart = second >= next ? next : (second >= current ? current : previous) + return (second - yearStart) / Solar.secondsPerDay + 1 + } + + /// The inverse of `t(forEpoch:)`, on the year the map was built around. + func epoch(forT t: Double) -> TimeInterval { + return current + (t - 1) * Solar.secondsPerDay + } + } + + /// Whether the sun's elevation at the instant is above `zenith`. + fileprivate func sunIsUp(atEpoch epoch: TimeInterval, above zenith: Zenith) -> Bool { + return sunIsUp(atT: yearMap.t(forEpoch: epoch), above: zenith) + } + + /// Whether the sun's elevation at `t` (a fractional day-of-year) is above `zenith`. + fileprivate func sunIsUp(atT t: Double, above zenith: Zenith) -> Bool { + let sun = Solar.sunPosition(forT: t) + let ut = (t - floor(t)) * 24 + + // The clock relation inverted: the sun's hour angle at this instant, where 0° + // is solar noon. The drift term spans many multiples of 24, so take the + // remainder before normalising. + let drifted = (ut + lngHour) - sun.rightAscension + (Solar.siderealDrift * t) + Solar.clockOffset + let H = Solar.normalise(drifted.truncatingRemainder(dividingBy: 24), withMaximum: 24) + let hourAngle = (H * 15).degreesToRadians + + let sinElevation = sun.sinDeclination * sinLatitude + sun.cosDeclination * cosLatitude * cos(hourAngle) + return sinElevation >= cos(zenith.rawValue.degreesToRadians) + } + + /// The solar transit (solar noon) nearest `guess`, as a fractional day-of-year: + /// the t at which the hour angle in `sunIsUp` vanishes. Right ascension drifts + /// slowly, so two passes of the fixed point converge far below a second. + fileprivate func solarTransitT(near guess: Double) -> Double { + var t = guess + for _ in 0..<2 { + let target = Solar.sunPosition(forT: t).rightAscension - lngHour - Solar.clockOffset + let cycles = (((24 + Solar.siderealDrift) * t - target) / 24).rounded() + t = (target + 24 * cycles) / (24 + Solar.siderealDrift) + } + return t + } + + /// The sunrise/sunset pair for one zenith, both bisected from the same anchor. + fileprivate func events(at zenith: Zenith, transitEpoch: TimeInterval) -> (sunrise: Date?, sunset: Date?) { + return (crossing(.sunrise, at: zenith, transitEpoch: transitEpoch), + crossing(.sunset, at: zenith, transitEpoch: transitEpoch)) + } + + /// The published event around the solar noon at `transitEpoch`: the first whole + /// second at which the sun is up (sunrise) or no longer up (sunset), found by + /// bisecting the elevation predicate against solar midnight. Elevation rises from + /// one solar midnight to noon and falls to the next, so a single crossing exists + /// exactly when the endpoints disagree; when they agree the sun stays on one side + /// of the zenith all day (polar day or night) and there is no event to publish. + fileprivate func crossing(_ sunriseSunset: SunriseSunset, at zenith: Zenith, transitEpoch: TimeInterval) -> Date? { + func afterEvent(_ epoch: TimeInterval) -> Bool { + let isUp = sunIsUp(atEpoch: epoch, above: zenith) + return sunriseSunset == .sunrise ? isUp : !isUp + } + + let halfDay = Solar.secondsPerDay / 2 + var before = sunriseSunset == .sunrise ? transitEpoch - halfDay : transitEpoch + var after = sunriseSunset == .sunrise ? transitEpoch : transitEpoch + halfDay + + guard !afterEvent(before), afterEvent(after) else { return nil } - - // Finish calculating H and convert into hours - let tempH = sunriseSunset == .sunrise ? 360 - acos(cosH).radiansToDegrees : acos(cosH).radiansToDegrees - let H = tempH / 15.0 - - // Calculate local mean time of rising - let T = H + RA - (0.06571 * t) - 6.622 - - // Adjust time back to UTC - var UT = T - lngHour - - // Normalise UT into [0, 24] range - UT = normalise(UT, withMaximum: 24) - - // Calculate all of the sunrise's / sunset's date components - let hour = floor(UT) - let minute = floor((UT - hour) * 60.0) - let second = (((UT - hour) * 60) - minute) * 60.0 - - let shouldBeYesterday = lngHour > 0 && UT > 12 && sunriseSunset == .sunrise - let shouldBeTomorrow = lngHour < 0 && UT < 12 && sunriseSunset == .sunset - - let setDate: Date - if shouldBeYesterday { - setDate = Date(timeInterval: -(60 * 60 * 24), since: date) - } else if shouldBeTomorrow { - setDate = Date(timeInterval: (60 * 60 * 24), since: date) - } else { - setDate = date + + while after - before > 1 { + let midpoint = ((before + after) / 2).rounded(.down) + if afterEvent(midpoint) { + after = midpoint + } else { + before = midpoint + } } - - var components = calendar.dateComponents([.day, .month, .year], from: setDate) - components.hour = Int(hour) - components.minute = Int(minute) - components.second = Int(second) - - calendar.timeZone = utcTimezone - return calendar.date(from: components) + return Date(timeIntervalSince1970: after) } /// Normalises a value between 0 and `maximum`, by adding or subtracting `maximum` - fileprivate func normalise(_ value: Double, withMaximum maximum: Double) -> Double { + fileprivate static func normalise(_ value: Double, withMaximum maximum: Double) -> Double { var value = value if value < 0 { @@ -202,41 +304,6 @@ public struct Solar { } -extension Solar { - - fileprivate static let secondsPerDay: TimeInterval = 60 * 60 * 24 - - /// Whether the location specified by the `latitude` and `longitude` is in daytime on `date`. - /// The `sunrise` / `sunset` window is anchored to the UTC calendar day of `date`, so when - /// the local solar day straddles UTC midnight the window containing `date` can belong to - /// the previous or next UTC day; those windows are checked too. - /// - Complexity: O(1) - public var isDaytime: Bool { - if let sunrise = sunrise, let sunset = sunset, date >= sunrise, date < sunset { - return true - } - - return [-Solar.secondsPerDay, Solar.secondsPerDay].contains { dayOffset in - let candidateDate = date.addingTimeInterval(dayOffset) - guard - let sunrise = calculate(.sunrise, for: candidateDate, and: .official), - let sunset = calculate(.sunset, for: candidateDate, and: .official) - else { - return false - } - - return date >= sunrise && date < sunset - } - } - - /// Whether the location specified by the `latitude` and `longitude` is in nighttime on `date` - /// - Complexity: O(1) - public var isNighttime: Bool { - return !isDaytime - } - -} - // MARK: - Helper extensions private extension Double { diff --git a/SolarTests/SolarTests.swift b/SolarTests/SolarTests.swift index be03bac..d5caf52 100644 --- a/SolarTests/SolarTests.swift +++ b/SolarTests/SolarTests.swift @@ -46,7 +46,7 @@ struct SolarTests { @Test("Sunrise is nil when no sunrise occurs") func sunriseIsNilWhenNoSunriseOccurs() { - let solar = Solar(for: Self.testDate, coordinate: CLLocationCoordinate2D(latitude: 78.2186, longitude: 15.64007)) // Location: Longyearbyen + let solar = Solar(for: Self.testDate, coordinate: Self.longyearbyen) #expect(solar != nil) #expect(solar?.sunrise == nil) } @@ -62,11 +62,128 @@ struct SolarTests { @Test("Sunset is nil when no sunset occurs") func sunsetIsNilWhenNoSunsetOccurs() { - let solar = Solar(for: Self.testDate, coordinate: CLLocationCoordinate2D(latitude: 78.2186, longitude: 15.64007)) // Location: Longyearbyen + let solar = Solar(for: Self.testDate, coordinate: Self.longyearbyen) #expect(solar != nil) #expect(solar?.sunset == nil) } + // MARK: - Consistency between published times and isDaytime + // + // A published sunrise is the first whole second at which the sun is up, and a + // published sunset the first whole second at which it is no longer up, so + // isDaytime must flip exactly at the published instants — for every location, + // including the high latitudes where the sun grazes the horizon. + + /// Asserts isDaytime flips exactly at `event`: it matches `rising` at the event + /// and is opposite one second before. + private func expectExactFlip(rising: Bool, at event: Date, coordinate: CLLocationCoordinate2D, label: String) throws { + let atEvent = try #require(Solar(for: event, coordinate: coordinate)) + let justBefore = try #require(Solar(for: event.addingTimeInterval(-1), coordinate: coordinate)) + + #expect(atEvent.isDaytime == rising, "\(label): isDaytime is \(atEvent.isDaytime) at the published event \(event)") + #expect(justBefore.isDaytime != rising, "\(label): isDaytime is \(justBefore.isDaytime) a second before the published event \(event)") + } + + @Test("isDaytime flips exactly at the published sunrise", arguments: cities) + func isDaytimeFlipsExactlyAtPublishedSunrise(for city: City) throws { + let solar = try #require(Solar(for: Self.testDate, coordinate: city.coordinate)) + let sunrise = try #require(solar.sunrise) + + try expectExactFlip(rising: true, at: sunrise, coordinate: city.coordinate, label: city.name) + } + + @Test("isDaytime flips exactly at the published sunset", arguments: cities) + func isDaytimeFlipsExactlyAtPublishedSunset(for city: City) throws { + let solar = try #require(Solar(for: Self.testDate, coordinate: city.coordinate)) + let sunset = try #require(solar.sunset) + + try expectExactFlip(rising: false, at: sunset, coordinate: city.coordinate, label: city.name) + } + + /// Regimes that pin the bisection's endpoint and year-mapping behaviour: + /// grazing crossings near the polar circles, the near-pole equinox, a solar day + /// straddling UTC midnight at the antimeridian, and an event landing in the + /// previous calendar year. + struct HardCase: CustomStringConvertible { + let name: String + let coordinate: CLLocationCoordinate2D + let date: Date + var description: String { name } + + init(_ name: String, _ latitude: Double, _ longitude: Double, _ epoch: TimeInterval) { + self.name = name + self.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + self.date = Date(timeIntervalSince1970: epoch) + } + } + + private static let antimeridianMidwinter = HardCase("Antimeridian, midwinter", 69.93, -180.0, 1737028800) // 2025-01-16 12:00 UTC + + private static let hardCases: [HardCase] = [ + HardCase("Arctic circle, midwinter", 67.5, 0.0, 1735732800), // 2025-01-01 12:00 UTC + HardCase("Near-pole, equinox", 89.5, -150.0, 1742212800), // 2025-03-17 12:00 UTC + antimeridianMidwinter, + HardCase("Cross-year sunrise", -62.5, 90.0, 1735732800), // 2025-01-01 12:00 UTC + ] + + @Test("isDaytime flips exactly at the published events in hard cases", arguments: hardCases) + func isDaytimeFlipsExactlyAtPublishedEventsInHardCases(for hardCase: HardCase) throws { + let solar = try #require(Solar(for: hardCase.date, coordinate: hardCase.coordinate)) + let sunrise = try #require(solar.sunrise, "\(hardCase.name): no sunrise published") + let sunset = try #require(solar.sunset, "\(hardCase.name): no sunset published") + + try expectExactFlip(rising: true, at: sunrise, coordinate: hardCase.coordinate, label: hardCase.name) + try expectExactFlip(rising: false, at: sunset, coordinate: hardCase.coordinate, label: hardCase.name) + } + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + @Test("Events near the antimeridian are attributed to the correct UTC day") + func eventsNearAntimeridianAreAttributedToCorrectUTCDay() throws { + // 69.93°N, 180°W on 2025-01-16: the local solar day straddles UTC midnight, and + // a single-wrap normalisation used to push the sunrise onto 2025-01-17 (~11 min + // late). The true crossing is late on the 16th. + let hardCase = Self.antimeridianMidwinter + + let solar = try #require(Solar(for: hardCase.date, coordinate: hardCase.coordinate)) + let sunrise = try #require(solar.sunrise) + + #expect(Self.utcCalendar.component(.day, from: sunrise) == 16, "sunrise \(sunrise) attributed to the wrong UTC day") + } + + // MARK: - Year boundary + // + // An event can land in the adjacent calendar year: a Tokyo sunrise on 1 Jan falls + // on 31 Dec UTC, a Los Angeles sunset on 31 Dec falls on 1 Jan UTC. + + private static let losAngeles = CLLocationCoordinate2D(latitude: 34.05, longitude: -118.24) + + @Test("Sunrise is published when it falls in the previous calendar year") + func sunriseIsPublishedWhenItFallsInPreviousYear() throws { + // Tokyo, 2021-01-01 12:00 UTC — sunrise is 2020-12-31 ~21:51 UTC. + let newYearsDay = Date(timeIntervalSince1970: 1609502400) + + let solar = try #require(Solar(for: newYearsDay, coordinate: Self.tokyo)) + + #expect(solar.sunrise != nil, "sunrise is nil on \(newYearsDay)") + #expect(solar.civilSunrise != nil, "civilSunrise is nil on \(newYearsDay)") + } + + @Test("Sunset is published when it falls in the next calendar year") + func sunsetIsPublishedWhenItFallsInNextYear() throws { + // Los Angeles, 2021-12-31 12:00 UTC — sunset is 2022-01-01 ~00:53 UTC. + let newYearsEve = Date(timeIntervalSince1970: 1640952000) + + let solar = try #require(Solar(for: newYearsEve, coordinate: Self.losAngeles)) + + #expect(solar.sunset != nil, "sunset is nil on \(newYearsEve)") + #expect(solar.civilSunset != nil, "civilSunset is nil on \(newYearsEve)") + } + @Test("isDaytime is true between sunrise and sunset") func isDaytimeIsTrueBetweenSunriseAndSunset() throws { let daytime = Date(timeIntervalSince1970: 1486641600) // noon @@ -178,6 +295,187 @@ struct SolarTests { #expect(solar.isNighttime, "isNighttime is false for date: \(nightBeforeSunrise) with sunrise: \(solar.sunrise!), sunset: \(solar.sunset!)") } + // MARK: - Polar day / polar night + // + // Above the polar circles there are days with no sunrise/sunset at all. `sunrise` + // and `sunset` are nil on such days, but isDaytime must still reflect reality: + // true all day during polar day (midnight sun), false all day during polar night. + + /// Lofoten, Norway (67.95°N) — above the Arctic Circle, 24h daylight in early June. + /// Coordinates and date taken from the report in issue #59. + private static let lofoten = CLLocationCoordinate2D(latitude: 67.94753132376813, longitude: 13.131613209843637) + + /// Longyearbyen, Svalbard (78.22°N) — polar night in early February. + private static let longyearbyen = CLLocationCoordinate2D(latitude: 78.2186, longitude: 15.64007) + + @Test("isDaytime is true during polar day") + func isDaytimeIsTrueDuringPolarDay() throws { + // Lofoten, 2022-06-03 06:00 UTC. The sun does not set at all on this date. + let polarDayMorning = Date(timeIntervalSince1970: 1654236000) + + let solar = try #require(Solar(for: polarDayMorning, coordinate: Self.lofoten)) + + #expect(solar.isDaytime, "isDaytime is false during polar day for date: \(polarDayMorning)") + #expect(!solar.isNighttime, "isNighttime is true during polar day for date: \(polarDayMorning)") + } + + @Test("isDaytime is true at local midnight during polar day") + func isDaytimeIsTrueAtLocalMidnightDuringPolarDay() throws { + // Lofoten, 2022-06-03 22:00 UTC (midnight CEST) — midnight sun, the sun is still up. + let midnightSun = Date(timeIntervalSince1970: 1654293600) + + let solar = try #require(Solar(for: midnightSun, coordinate: Self.lofoten)) + + #expect(solar.isDaytime, "isDaytime is false under the midnight sun for date: \(midnightSun)") + #expect(!solar.isNighttime, "isNighttime is true under the midnight sun for date: \(midnightSun)") + } + + /// West Greenland (67.0°N, 50.0°W, near Kangerlussuaq) — chosen because its entry into + /// polar day in 2022 produces a rare mixed day: 2022-06-02 has a final sunrise at + /// 03:21 UTC but no sunset, after which the sun stays up. + private static let westGreenland = CLLocationCoordinate2D(latitude: 67.0, longitude: -50.0) + + @Test("isDaytime is true after the final sunrise on the first day of polar day") + func isDaytimeIsTrueAfterFinalSunriseOnFirstDayOfPolarDay() throws { + // West Greenland, 2022-06-02 12:00 UTC — after the day's 03:21 UTC sunrise, + // and the sun will not set again. + let afterFinalSunrise = Date(timeIntervalSince1970: 1654171200) + + let solar = try #require(Solar(for: afterFinalSunrise, coordinate: Self.westGreenland)) + + #expect(solar.isDaytime, "isDaytime is false after the final sunrise for date: \(afterFinalSunrise)") + #expect(!solar.isNighttime, "isNighttime is true after the final sunrise for date: \(afterFinalSunrise)") + } + + @Test("isDaytime is true late on the first day of polar day") + func isDaytimeIsTrueLateOnFirstDayOfPolarDay() throws { + // West Greenland, 2022-06-02 23:00 UTC. The sun rose at 03:21 UTC and never set; + // the following UTC days are fully polar, so no adjacent day supplies a window. + let lateEvening = Date(timeIntervalSince1970: 1654210800) + + let solar = try #require(Solar(for: lateEvening, coordinate: Self.westGreenland)) + + #expect(solar.isDaytime, "isDaytime is false late on the first polar day for date: \(lateEvening)") + #expect(!solar.isNighttime, "isNighttime is true late on the first polar day for date: \(lateEvening)") + } + + @Test("isDaytime is true before the first sunset on the last day of polar day") + func isDaytimeIsTrueBeforeFirstSunsetOnLastDayOfPolarDay() throws { + // Lofoten, 2022-07-17 12:00 UTC. The sun has been up for weeks; the first sunset + // in weeks comes at 22:57 UTC, so midday has no sunrise yet must be daytime. + let middayBeforeFirstSunset = Date(timeIntervalSince1970: 1658059200) + + let solar = try #require(Solar(for: middayBeforeFirstSunset, coordinate: Self.lofoten)) + + #expect(solar.isDaytime, "isDaytime is false before the first sunset for date: \(middayBeforeFirstSunset)") + #expect(!solar.isNighttime, "isNighttime is true before the first sunset for date: \(middayBeforeFirstSunset)") + } + + @Test("isDaytime is false after the first sunset on the last day of polar day") + func isDaytimeIsFalseAfterFirstSunsetOnLastDayOfPolarDay() throws { + // Lofoten, 2022-07-17 23:15 UTC — inside the first night in weeks, between the + // 22:57 UTC sunset and the ~23:40 UTC sunrise of the next solar day. + let firstNight = Date(timeIntervalSince1970: 1658099700) + + let solar = try #require(Solar(for: firstNight, coordinate: Self.lofoten)) + + #expect(!solar.isDaytime, "isDaytime is true during the first night for date: \(firstNight)") + #expect(solar.isNighttime, "isNighttime is false during the first night for date: \(firstNight)") + } + + @Test("isDaytime is false at midday during polar night") + func isDaytimeIsFalseAtMiddayDuringPolarNight() throws { + // Longyearbyen, 2017-02-09 12:00 UTC (~13:00 local). The sun does not rise at all on + // this date, so even at midday it is not daytime. + let polarNightMidday = Date(timeIntervalSince1970: 1486641600) + + let solar = try #require(Solar(for: polarNightMidday, coordinate: Self.longyearbyen)) + + #expect(!solar.isDaytime, "isDaytime is true during polar night for date: \(polarNightMidday)") + #expect(solar.isNighttime, "isNighttime is false during polar night for date: \(polarNightMidday)") + } + + // MARK: Polar night transition days + // + // Entering and leaving polar night also produces mixed days, where the sun is up + // only for a short sliver around solar noon: on the last day of polar night the + // sunrise calculation still reports "sun never rises" while a real sunset exists, + // and on the first day the reverse. The sliver must read as daytime, but the rest + // of those days must remain night. + + /// Siberia near Norilsk (69.1°N, 90.0°E) — leaves polar night on 2021-01-12, when the + /// algorithm finds no sunrise but a real sunset at 06:29 UTC. The implied daylight + /// sliver (~05:55–06:29 UTC) is ~34 minutes, wide enough to test its midpoint with + /// a margin well beyond the algorithm's ~5 minute accuracy. + private static let norilsk = CLLocationCoordinate2D(latitude: 69.1, longitude: 90.0) + + /// Norwegian Sea off Andøya (69.5°N, 15.0°E) — enters polar night on 2021-11-28, when + /// the algorithm finds a real sunrise at 10:26 UTC but no sunset. The implied daylight + /// sliver (~10:26–11:01 UTC) is ~35 minutes. + private static let andoya = CLLocationCoordinate2D(latitude: 69.5, longitude: 15.0) + + @Test("isDaytime is true during the daylight sliver on the last day of polar night") + func isDaytimeIsTrueDuringDaylightSliverOnLastDayOfPolarNight() throws { + // Norilsk, 2021-01-12 06:12 UTC — the midpoint of the day's ~34 minute daylight + // sliver, ~17 minutes from either edge. + let insideSliver = Date(timeIntervalSince1970: 1610431920) + + let solar = try #require(Solar(for: insideSliver, coordinate: Self.norilsk)) + + #expect(solar.isDaytime, "isDaytime is false inside the daylight sliver for date: \(insideSliver)") + #expect(!solar.isNighttime, "isNighttime is true inside the daylight sliver for date: \(insideSliver)") + } + + @Test("isDaytime is false in the morning on the last day of polar night") + func isDaytimeIsFalseInMorningOnLastDayOfPolarNight() throws { + // Norilsk, 2021-01-12 03:00 UTC — hours before the daylight sliver begins. + // Guards against recovering the sliver by treating the whole day as daytime. + let darkMorning = Date(timeIntervalSince1970: 1610420400) + + let solar = try #require(Solar(for: darkMorning, coordinate: Self.norilsk)) + + #expect(!solar.isDaytime, "isDaytime is true in the dark morning for date: \(darkMorning)") + #expect(solar.isNighttime, "isNighttime is false in the dark morning for date: \(darkMorning)") + } + + @Test("isDaytime is true during the daylight sliver on the first day of polar night") + func isDaytimeIsTrueDuringDaylightSliverOnFirstDayOfPolarNight() throws { + // Andøya, 2021-11-28 10:43 UTC — the midpoint of the day's ~35 minute daylight + // sliver, ~17 minutes from either edge. + let insideSliver = Date(timeIntervalSince1970: 1638096180) + + let solar = try #require(Solar(for: insideSliver, coordinate: Self.andoya)) + + #expect(solar.isDaytime, "isDaytime is false inside the daylight sliver for date: \(insideSliver)") + #expect(!solar.isNighttime, "isNighttime is true inside the daylight sliver for date: \(insideSliver)") + } + + @Test("isDaytime is false in the morning on the first day of polar night") + func isDaytimeIsFalseInMorningOnFirstDayOfPolarNight() throws { + // Andøya, 2021-11-28 07:00 UTC — hours before the 10:26 UTC sunrise. + let darkMorning = Date(timeIntervalSince1970: 1638082800) + + let solar = try #require(Solar(for: darkMorning, coordinate: Self.andoya)) + + #expect(!solar.isDaytime, "isDaytime is true in the dark morning for date: \(darkMorning)") + #expect(solar.isNighttime, "isNighttime is false in the dark morning for date: \(darkMorning)") + } + + @Test("isDaytime is true when polar day daylight crosses UTC midnight into a transition day") + func isDaytimeIsTrueWhenPolarDayDaylightCrossesUTCMidnightIntoTransitionDay() throws { + // Antarctica (78.13°S, 146.32°W), 2026-02-20 02:00 UTC — local solar afternoon at the + // tail of polar day. The previous UTC day is full polar day and this UTC day's first + // sunset comes hours later, so the continuous daylight spans the UTC midnight between + // a polar day and a transition day. Found by fuzzing against a solar elevation oracle. + let daylightAcrossMidnight = Date(timeIntervalSince1970: 1771552800) + let antarctica = CLLocationCoordinate2D(latitude: -78.13, longitude: -146.32) + + let solar = try #require(Solar(for: daylightAcrossMidnight, coordinate: antarctica)) + + #expect(solar.isDaytime, "isDaytime is false while the sun is up for date: \(daylightAcrossMidnight)") + #expect(!solar.isNighttime, "isNighttime is true while the sun is up for date: \(daylightAcrossMidnight)") + } + @Test("Solar init returns nil given an invalid coordinate") func solarInitReturnsNilGivenInvalidCoordinate() { let invalidCoordinate1 = CLLocationCoordinate2D(latitude: -100, longitude: 0)