From ee2924aad604a0bab94dc0da402936fa335c8e58 Mon Sep 17 00:00:00 2001 From: Chris Howell Date: Tue, 1 Sep 2026 00:00:18 +0100 Subject: [PATCH] fix: isDaytime wrong during polar day (#59) During polar day (24h daylight above the polar circles) isDaytime returned false and isNighttime true - the opposite of reality. The window-based check collapsed "sun never rises" and "sun never sets" into the same nil sunrise/sunset, so both fell through to false. isDaytime now asks the question directly: is the sun's elevation at date above the official zenith? The USNO Almanac for Computers position model moves into sunPosition(forT:), and sunIsUp(atT:above:) inverts the almanac's clock relation to run the altitude identity forward at any instant. Polar day, polar night, transition days, and daylight spanning UTC midnight need no special cases. The published events are then defined by that same predicate rather than by the almanac's one-shot anchored inversion: each is the first whole second on the far side of the horizon crossing, found by bisecting the predicate between the model's solar transit and solar midnight. Elevation is unimodal over a solar day, so a crossing exists exactly when the endpoints disagree - which also replaces the cosH polar guards. isDaytime and the published times derive from one predicate and cannot disagree, at any latitude, including grazing crossings near the polar circles where the old one-shot solve drifted by up to an hour. Deleting the inversion also deletes its failure modes: the single-wrap normalise that pushed events near longitude 180 onto the wrong UTC day, the shouldBeYesterday/Tomorrow day-shift heuristics, and the year boundary discontinuity (instants map to their own year's day-of-year via epoch arithmetic, so a fresh Solar at a published cross-year event agrees exactly). Verified: 32/32 tests, including exact isDaytime flips at both sides of every published event for all city fixtures and for grazing, near-pole, antimeridian and cross-year cases; 0 contract violations in a 16,944- check seeded fuzz (2020-2028). Accuracy vs the NOAA-derived fixtures: mean 51.8s (main: 52.2s), max 251s (main: 268s). Cost: ~14us per init vs ~6us on main. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VPzRUnPKS8j3WimEjyb2Jk --- Solar/Solar.swift | 293 ++++++++++++++++++++-------------- SolarTests/SolarTests.swift | 302 +++++++++++++++++++++++++++++++++++- 2 files changed, 480 insertions(+), 115 deletions(-) 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)