diff options
Diffstat (limited to 'Shared/Utilities/SunTimes.swift')
| -rw-r--r-- | Shared/Utilities/SunTimes.swift | 96 |
1 files changed, 96 insertions, 0 deletions
diff --git a/Shared/Utilities/SunTimes.swift b/Shared/Utilities/SunTimes.swift new file mode 100644 index 0000000..bbb7a8d --- /dev/null +++ b/Shared/Utilities/SunTimes.swift @@ -0,0 +1,96 @@ +import Foundation + +class SunTimes { + let latitude: Double + let longitude: Double + let date: Date + + init(latitude: Double, longitude: Double, date: Date) { + self.latitude = latitude + self.longitude = longitude + self.date = date + } + + func sunrise() -> Date? { + return calculateSunTimes()?.sunrise + } + + func sunset() -> Date? { + return calculateSunTimes()?.sunset + } + + // NOAA solar position algorithm with standard horizon correction. + // Uses zenith = 90.833° to account for atmospheric refraction (~0.5666°) + // and solar disk radius (~0.2667°), matching skyfield's almanac.sunrise_sunset. + // Reference: https://www.esrl.noaa.gov/gmd/grad/solcalc/ + private func calculateSunTimes() -> (sunrise: Date, sunset: Date)? { + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year else { return nil } + + let jan1 = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! + let dayOfYear = calendar.dateComponents([.day], from: jan1, to: date).day! + 1 + + let daysInYear = Double(isLeapYear(year) ? 366 : 365) + let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / daysInYear + + let decl = 0.006918 - 0.399912 * cos(gamma) + 0.070257 * sin(gamma) + - 0.006758 * cos(2.0 * gamma) + 0.000907 * sin(2.0 * gamma) + - 0.002697 * cos(3.0 * gamma) + 0.00148 * sin(3.0 * gamma) + + let eot = 229.18 * (0.000075 + 0.001868 * cos(gamma) - 0.032077 * sin(gamma) + - 0.014615 * cos(2.0 * gamma) - 0.040849 * sin(2.0 * gamma)) + + let zenith = 90.833 * Double.pi / 180.0 + let latRad = latitude * Double.pi / 180.0 + let cosH = (cos(zenith) - sin(latRad) * sin(decl)) / (cos(latRad) * cos(decl)) + + guard cosH >= -1.0 && cosH <= 1.0 else { return nil } + + let h = acos(cosH) * 180.0 / Double.pi + + let solarNoonUTC = 12.0 - (longitude / 15.0) - (eot / 60.0) + let sunriseUTC = solarNoonUTC - (h / 15.0) + let sunsetUTC = solarNoonUTC + (h / 15.0) + + let tzOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0 + let sunriseLocal = sunriseUTC + tzOffset + let sunsetLocal = sunsetUTC + tzOffset + + let currentCalendar = Calendar.current + let baseComponents = currentCalendar.dateComponents([.year, .month, .day], from: date) + + guard let sunriseDate = buildDate(from: baseComponents, decimalHours: sunriseLocal, calendar: currentCalendar), + let sunsetDate = buildDate(from: baseComponents, decimalHours: sunsetLocal, calendar: currentCalendar) else { + return nil + } + + return (sunriseDate, sunsetDate) + } + + private func buildDate(from base: DateComponents, decimalHours: Double, calendar: Calendar) -> Date? { + let hour = Int(decimalHours) + let minute = Int((decimalHours - Double(hour)) * 60.0) + var comps = base + + if hour < 0 { + guard let prevDay = calendar.date(byAdding: .day, value: -1, to: date) else { return nil } + comps = calendar.dateComponents([.year, .month, .day], from: prevDay) + comps.hour = 24 + hour + } else if hour >= 24 { + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { return nil } + comps = calendar.dateComponents([.year, .month, .day], from: nextDay) + comps.hour = hour - 24 + } else { + comps.hour = hour + } + comps.minute = max(0, min(59, minute)) + comps.second = 0 + + return calendar.date(from: comps) + } + + private func isLeapYear(_ year: Int) -> Bool { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + } +} |
