SunKit
SunKit is a Swift package which uses math and trigonometry to compute several information about the Sun. This package has been developed by a team of learners relatively new to the Swift programming language, which means that there could be a lot of space for improvements. Every contribution is welcome.
SunKit was first developed as part of a bigger project: Sunlitt. Even though Sunlitt is not meant to be released as Open Source we decided to wrap the fundamental logic of the app and make an open source library out of it.
To compute Sunrise, Sunset, Golden Hour and so on we only need a CLLocation and the time zone of that location. CoreLocation and SwiftUI framework are required for SunKit to work.
Local Solar Time Meridian
The Local Standard Time Meridian is a reference meridian used for a particular time zone and is similar to the Prime Meridian, which is used for Greenwich Mean Time. The LSTM is calculated according to the equation:
where
private var localStandardTimeMeridian: Double {
return timeZone * 15
}
The Equation Of Time
The equation of time (EoT) (in minutes) is an empirical equation that corrects for the eccentricity of the Earth’s orbit and the Earth’s axial tilt. An approximation 2 accurate to within
where:
in degrees and
private var b: Angle {
let angleInDegrees: Double = (360 / 365 * Double(daysPassedFromStartOfYear  81))
return .degrees(angleInDegrees)
}
private var equationOfTime: Double {
let bRad = b.radians
return 9.87 * sin(2 * bRad)  7.53 * cos(bRad)  1.5 * sin(bRad)
}
private func getDaysPassedFromStartOfTheYear() throws > Int {
let year = calendar.component(.year, from: date)
dateFormatter.dateFormat = "yyyy/mm/dd"
dateFormatter.calendar = calendar
guard let dataFormatted = dateFormatter.date(from: "\(year)/01/01") else {
throw SunError.unableToGenerateStartOfTheYear(from: date)
}
let startOfYear = calendar.startOfDay(for: dataFormatted)
let startOfDay = calendar.startOfDay(for: date)
guard var daysPassedFromStartOfTheYear = calendar.dateComponents([.day], from: startOfYear, to: startOfDay).day else {
throw SunError.unableToGenerateDaysPassedFromStartOfTheYear(from: startOfYear, to: startOfDay)
}
//It Doesn't count the current day
daysPassedFromStartOfTheYear = daysPassedFromStartOfTheYear + 1
return daysPassedFromStartOfTheYear
}
The calendar variable it’s a private variable inside the Sun struct, it’s initialised with the gregorian identifier. We need this to compute the days passed from the start of the year by using a gregorian calendar, it could in fact happen that the user uses a Japanese or a Buddhist calendar.
private var calendar: Calendar = .init(identifier: .gregorian)
Time Correction Factor
The Time Correction Factor (in minutes) accounts for the variation of the Local Solar Time (LST) within a given time zone due to the longitude variations within the time zone and also incorporates the EoT above.
The factor of 4 minutes comes from the fact that the Earth rotates
private var timeCorrectionFactorInSeconds: Double {
let timeCorrectionFactor = 4 * (location.coordinate.longitude  localStandardTimeMeridian) + equationOfTime
let minutes: Double = Double(Int(timeCorrectionFactor) * 60)
let seconds: Double = timeCorrectionFactor.truncatingRemainder(dividingBy: 1) * 100
let timeCorrectionFactorInSeconds = minutes + seconds
return timeCorrectionFactorInSeconds
}
The timeCorrectionFactor variable it’s in the form mm:ss, that’s why we extract first the minutes and then the seconds to compute timeCorrectionFactorInSeconds variable that we need.
Local Solar Time
Twelve noon local solar time (LST) is defined as when the sun is highest in the sky. Local time (LT) usually varies from LST because of the eccentricity of the Earth’s orbit, and because of human adjustments such as time zones and daylight saving.
The Local Solar Time (LST) can be found by using the previous two corrections to adjust the local time (LT).
private func getLocalSolarTime() throws > Double {
guard let localSolarDate = calendar.date(byAdding: .second, value: Int(timeCorrectionFactorInSeconds), to: date) else {
throw SunError.unableToGenerateLocalSolarDate(from: date, byAdding: timeCorrectionFactorInSeconds)
}
dateFormatter.dateFormat = "mm"
guard var localSolarTimeMinute = Double(dateFormatter.string(from: localSolarDate)) else {
throw SunError.unableToGenerateLocalSolarTimeMinute(from: localSolarDate)
}
localSolarTimeMinute = localSolarTimeMinute / 100
let localSolarTimeHour = Double(calendar.component(.hour, from: localSolarDate))
localSolarTimeMinute *= 0.5084745763 / 0.299
return localSolarTimeHour + localSolarTimeMinute
}
The date variable inside the Sun struct gives us the time at which we have to compute the azimuth. But before compute it we need to compute the Local Solar Time. Basically here the second row compute the equation. We don’t need to divide the TC beacuse we already have it in seconds. We then extract the minutes and the hour from the date. For computation purpouse, we use a proportion to move the variable localSolarTimeMinute from [0,59] range to [0,100].
Hour Angle
The Hour Angle converts the local solar time (LST) into the number of degrees which the sun moves across the sky. By definition, the Hour Angle is
private var hourAngle: Angle {
let angleInDegrees: Double = (localSolarTime  12.0) * 15
return .init(degrees: angleInDegrees)
}
Declination Angle
The declination angle, denoted by
private var declination: Angle {
let bRad = b.radians
let declinationInDegree: Double = 23.45 * sin(bRad)
return .init(degrees: declinationInDegree)
}
Elevation Angle
The elevation angle (used interchangeably with altitude angle) is the angular height of the sun in the sky measured from the horizontal. Confusingly, both altitude and elevation are also used to describe the height in meters above sea level. The elevation is
Here
public var elevation: Angle {
get {
let declinationRad = declination.radians
let hourAngleRad = hourAngle.radians
let latitude: Angle = .degrees(location.coordinate.latitude)
let latitudeRad = latitude.radians
var elevationArg = sin(declinationRad) * sin(latitudeRad) + cos(declinationRad) * cos(latitudeRad) * cos(hourAngleRad)
elevationArg = checkDomainForArcSinCosineFunction(argument: elevationArg)
let elevationInRadians: Double = asin(elevationArg)
return .init(radians: elevationInRadians)
}
}
Azimuth Angle
The azimuth angle is the compass direction from which the sunlight is coming. At solar noon, the sun is always directly south in the northern hemisphere and directly north in the southern hemisphere. The azimuth angle varies throughout the day as shown in the animation below. At the equinoxes, the sun rises directly east and sets directly west regardless of the latitude, thus making the azimuth angles 90° at sunrise and 270° at sunset. In general however, the azimuth angle varies with the latitude and time of year and the full equations to calculate the sun’s position throughout the day are given on the following page.
It can be calculated as follow:
private func getAzimuth() throws > Double {
let hourAngleRad = hourAngle.radians
let declinationRad = declination.radians
let latitude: Angle = .degrees(location.coordinate.latitude)
let latitudeRad = latitude.radians
let elevationRad = elevation.radians
var azimuthArg = (sin(declinationRad) * cos(latitudeRad)  cos(declinationRad) * sin(latitudeRad) * cos(hourAngleRad)) / cos(elevationRad)
azimuthArg = checkDomainForArcSinCosineFunction(argument: azimuthArg)
let azimuthRad: Double = acos(azimuthArg)
guard !azimuthRad.isInfinite else {
throw SunError.azimuthIsInfinite
}
let azimuthAngle: Angle = .init(radians: azimuthRad)
var azimuth = azimuthAngle.degrees
if localSolarTime > 12 {
azimuth = 360  azimuth
}
return azimuth
}
The above equation only gives the correct azimuth in the solar morning so that:

$$(Azimuth = A_{zi}, for LST <12)$$ 
$$(Azimuth = 360°  A_{zi}, for LST > 12)$$
The checkDomainForArcSinCosineFunction simply check if the argument that will go inside the asin function or acos it’s inside the domain, that is betweent 1 and 1. It’s necessary beacuse with al these computations, it could happen that we could have as argument for acos function or asin function 1,003 instead of 1. This could happen if you pin yourself near one of the two poles.
private func checkDomainForArcSinCosineFunction(argument: Double) > Double {
let inDomainValue: Double
if argument > 1 {
inDomainValue = 1.0
} else if argument < 1 {
inDomainValue = 1.0
} else {
inDomainValue = argument
}
return inDomainValue
}
Sunrise, Sunset and Solar Noon
For the special case of sunrise or sunset, the zenith is set to (the approximate correction for atmospheric refraction at sunrise and sunset, and the size of the solar disk), and the hour angle becomes:
Then the UTC time of sunrise (or sunset) in minutes is:
For UTC solar noon:
Please note that this is for UTC Sunrise, Sunset and Solar Noon. We have to convert it in your local time based on your time zone.
private func getSunrise() throws > Date {
let latitude: Angle = .degrees(location.coordinate.latitude)
let latitudeRad = latitude.radians
let declinationRad = declination.radians
var haArg = (cos(Angle.degrees(90.833).radians)) / (cos(latitudeRad) * cos(declinationRad))  tan(latitudeRad) * tan(declinationRad)
haArg = checkDomainForArcSinCosineFunction(argument: haArg)
let ha: Angle = .init(radians: acos(haArg))
let sunriseUTCMinutes = 720  4 * (location.coordinate.longitude + ha.degrees)  equationOfTime
let sunriseSeconds = (sunriseUTCMinutes + timeZone * 60 ) * 60
let startOfDay = calendar.startOfDay(for: date)
guard var sunriseDate = calendar.date(byAdding: .second, value: Int(sunriseSeconds), to: startOfDay) else {
throw SunError.unableToGenerateSunriseDate(from: date)
}
let hoursMinutesSeconds: (Int,Int,Int) = secondsToHoursMinutesSeconds(Int(sunriseSeconds))
sunriseDate = calendar.date(bySettingHour: hoursMinutesSeconds.0 , minute: hoursMinutesSeconds.1, second: hoursMinutesSeconds.2, of: sunriseDate) ?? startOfDay
return sunriseDate
}
private func getSunset() throws > Date {
let secondsInOneDay: Double = 86399
let latitude: Angle = .degrees(location.coordinate.latitude)
let latitudeRad = latitude.radians
let declinationRad = declination.radians
var haArg = (cos(Angle.degrees(90.833).radians)) / (cos(latitudeRad) * cos(declinationRad))  tan(latitudeRad) * tan(declinationRad)
haArg = checkDomainForArcSinCosineFunction(argument: haArg)
let ha: Angle = .init(radians: acos(haArg))
let sunsetUTCMinutes = 720  4 * (location.coordinate.longitude + ha.degrees)  equationOfTime
var sunsetSeconds = (sunsetUTCMinutes + timeZone * 60 ) * 60
let startOfDay = calendar.startOfDay(for: date)
if sunsetSeconds > secondsInOneDay {
sunsetSeconds = 86399
}
guard var sunsetDate = calendar.date(byAdding: .second, value: Int(sunsetSeconds), to: startOfDay) else {
throw SunError.unableToGenerateSunsetDate(from: date)
}
let hoursMinutesSeconds: (Int,Int,Int) = secondsToHoursMinutesSeconds(Int(sunsetSeconds))
sunsetDate = calendar.date(bySettingHour: hoursMinutesSeconds.0 , minute: hoursMinutesSeconds.1, second: hoursMinutesSeconds.2, of: sunsetDate) ?? Date()
return sunsetDate
}
private func getSolarNoon() throws > Date {
let secondsForUTCSolarNoon = (720  4 * location.coordinate.longitude  equationOfTime) * 60
let secondsForSolarNoon = secondsForUTCSolarNoon + 3600 * timeZone
let startOfTheDay = calendar.startOfDay(for: date)
guard var solarNoon = calendar.date(byAdding: .second, value: Int(secondsForSolarNoon), to: startOfTheDay) else {
throw SunError.unableToGenerateSolarNoon(from: date)
}
let hoursMinutesSeconds: (Int,Int,Int) = secondsToHoursMinutesSeconds(Int(secondsForSolarNoon))
solarNoon = calendar.date(bySettingHour: hoursMinutesSeconds.0 , minute: hoursMinutesSeconds.1, second: hoursMinutesSeconds.2, of: solarNoon) ?? Date()
return solarNoon
}
Golden Hour
In photography, the golden hour is the period of daytime shortly after sunrise or before sunset, during which daylight is redder and softer than when the sun is higher in the sky.
By definition, golden hour starts when the sun it’s at
Do compute it we have the function getDateFrom(elevation : Angle), where we pass in it the angle and it outputs the time at which the sun will reach that elevation.
private func getGoldenHourStart() throws > Date {
let elevationSunGoldenHourStart: Angle = .init(degrees: 6.0)
guard let goldenHourStart = getDateFrom(elevation: elevationSunGoldenHourStart) else {
throw SunError.unableToGenerateGoldenHourStart(from: date)
}
return goldenHourStart
}
private func getGoldenHourFinish() throws > Date {
let elevationSunGoldenHourFinish: Angle = .init(degrees: 4.0)
guard let goldenHourFinish = getDateFrom(elevation: elevationSunGoldenHourFinish) else {
throw SunError.unableToGenerateGoldenHourFinish(from: date)
}
return goldenHourFinish
}
To get a date from elevation we need to do two steps, the first one it’s to compute HRA from the elevation in input. That is, 6° to get the get when the golden hour starts.
After that we need now to compute the local time (LT) with the following equation:
private func getDateFrom(elevation : Angle) > Date? {
let secondsInOneDay: Double = 86399
let elevationRad = elevation.radians
let latitude: Angle = .degrees(location.coordinate.latitude)
let latitudeRad = latitude.radians
let declinationRad = declination.radians
var cosHra = (sin(elevationRad)  sin(declinationRad) * sin(latitudeRad)) / (cos(declinationRad) * cos(latitudeRad))
cosHra = checkDomainForArcSinCosineFunction(argument: cosHra)
let hraAngle: Angle = .radians(acos(cosHra))
var secondsForSunToReachElevation = (hraAngle.degrees / 15) * 3600 + 43200  timeCorrectionFactorInSeconds
let startOfTheDay = calendar.startOfDay(for: date)
if secondsForSunToReachElevation > secondsInOneDay {
secondsForSunToReachElevation = 86399
}
let hoursMinutesSeconds: (Int,Int,Int) = secondsToHoursMinutesSeconds(Int(secondsForSunToReachElevation))
var newDate = calendar.date(byAdding: .second, value: Int(secondsForSunToReachElevation), to: startOfTheDay)
newDate = calendar.date(bySettingHour: hoursMinutesSeconds.0 , minute: hoursMinutesSeconds.1, second: hoursMinutesSeconds.2, of:newDate ?? Date())
return newDate
}
The variable hraAngle will store the result of the first equation.
Then we simply compute the second equation, and stores the value inside secondsForSunToReachElevation; where 43200 it’s simple 12 hour in seconds, and we multiply
The if secondsForSunToReachElevation > secondsInOneDay is used beacuse near the poles could happen that the sun will never reach that elevation, for example in summer in the north pole is always day. After added secondsForSunToReachElevation to startOfTheDay (i.e midnight of date), we extract hours, minutes and seconds from secondsForSunToReachElevation.
Then setting the hour, minute and seconds could seem redunant, but we need to do it because calendar.date(byAdding) will add one hour more the day where the timezone goes from +1 to +2. For example in Italy this happen 27 March. Also the viceversa will happen of course.
References
Special thanks
 Davide Biancardi: main developer of SunKit.