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 } /// Calculate sunrise time for the location and date func sunrise() -> Date? { guard let result = calculateSunTimes() else { return nil } return result.sunrise } /// Calculate sunset time for the location and date func sunset() -> Date? { guard let result = calculateSunTimes() else { return nil } return result.sunset } // NOAA solar position algorithm // 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 } // Step 1: Calculate day of year let jan1 = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! let dayOfYear = calendar.dateComponents([.day], from: jan1, to: date).day! + 1 // Step 2: Fractional year in radians let daysInYear = Double(isLeapYear(year) ? 366 : 365) let gamma = 2.0 * Double.pi * Double(dayOfYear - 1) / daysInYear // Step 3: Solar declination (radians) 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) // Step 4: Equation of time (minutes) 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)) // Step 5: Hour angle at sunrise/sunset let latRad = latitude * Double.pi / 180.0 let cosH = -tan(latRad) * tan(decl) guard cosH >= -1.0 && cosH <= 1.0 else { // Sun is always up or always down at this latitude/date return nil } let h = acos(cosH) * 180.0 / Double.pi // Step 6: Solar noon in UTC (decimal hours) let solarNoonUTC = 12.0 - (longitude / 15.0) - (eot / 60.0) // Step 7: Sunrise and sunset in UTC (decimal hours) let sunriseUTC = solarNoonUTC - (h / 15.0) let sunsetUTC = solarNoonUTC + (h / 15.0) // Step 8: Convert to local time (add timezone offset) let tzOffset = Double(TimeZone.current.secondsFromGMT(for: date)) / 3600.0 let sunriseLocal = sunriseUTC + tzOffset let sunsetLocal = sunsetUTC + tzOffset // Step 9: Create date components, handling day boundary crossing let calendar2 = Calendar.current let baseComponents = calendar2.dateComponents([.year, .month, .day], from: date) // Sunrise var sunriseComp = baseComponents let srHourDouble = sunriseLocal let srHour = Int(srHourDouble) let srMinuteDouble = (srHourDouble - Double(srHour)) * 60.0 let srMinute = Int(srMinuteDouble) if srHour < 0 { if let prevDay = calendar2.date(byAdding: .day, value: -1, to: date) { let prevComponents = calendar2.dateComponents([.year, .month, .day], from: prevDay) sunriseComp = prevComponents sunriseComp.hour = 24 + srHour } else { sunriseComp.hour = 0 } } else if srHour >= 24 { if let nextDay = calendar2.date(byAdding: .day, value: 1, to: date) { let nextComponents = calendar2.dateComponents([.year, .month, .day], from: nextDay) sunriseComp = nextComponents sunriseComp.hour = srHour - 24 } else { sunriseComp.hour = 23 } } else { sunriseComp.hour = srHour } sunriseComp.minute = max(0, min(59, srMinute)) sunriseComp.second = 0 // Sunset var sunsetComp = baseComponents let ssHourDouble = sunsetLocal let ssHour = Int(ssHourDouble) let ssMinuteDouble = (ssHourDouble - Double(ssHour)) * 60.0 let ssMinute = Int(ssMinuteDouble) if ssHour < 0 { if let prevDay = calendar2.date(byAdding: .day, value: -1, to: date) { let prevComponents = calendar2.dateComponents([.year, .month, .day], from: prevDay) sunsetComp = prevComponents sunsetComp.hour = 24 + ssHour } else { sunsetComp.hour = 0 } } else if ssHour >= 24 { if let nextDay = calendar2.date(byAdding: .day, value: 1, to: date) { let nextComponents = calendar2.dateComponents([.year, .month, .day], from: nextDay) sunsetComp = nextComponents sunsetComp.hour = ssHour - 24 } else { sunsetComp.hour = 23 } } else { sunsetComp.hour = ssHour } sunsetComp.minute = max(0, min(59, ssMinute)) sunsetComp.second = 0 guard let sunriseDate = calendar2.date(from: sunriseComp), let sunsetDate = calendar2.date(from: sunsetComp) else { return nil } return (sunriseDate, sunsetDate) } private func isLeapYear(_ year: Int) -> Bool { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) } }