summaryrefslogtreecommitdiffstats
path: root/Solsnu.Widget/Utilities/SunTimes.swift
blob: f8906ce2d6a7948b7055bd57a2dfb6ba8d8e9fd6 (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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
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
        var 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)
    }
}