summaryrefslogtreecommitdiffstats
path: root/Shared/Utilities/SunTimes.swift
blob: bbb7a8da18c9f267f5297f71f5b3b91f6398f148 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
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)
    }
}