JohnCoates/Aerial

View on GitHub
Aerial/Source/Models/Time/Solar.swift

Summary

Maintainability
C
1 day
Test Coverage
//
//  Solar.swift
//  SolarExample
//
//  Created by Chris Howell on 16/01/2016.
//  Copyright © 2016 Chris Howell. All rights reserved.
//
//  Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the “Software”), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
//  The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
//  THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.
//
//  Modifications for Aerial/glouel
//      26/10/2018: - added an intermediate mode that's closer to night shift sunset/sunrise times
//                  - added a isDaylight(zenith: Zenith) function

import Foundation
import CoreLocation

public struct Solar {

    /// The coordinate that is used for the calculation
    public let coordinate: CLLocationCoordinate2D

    /// The date to generate sunrise / sunset times for
    public fileprivate(set) var date: Date

    public fileprivate(set) var sunrise: Date?
    public fileprivate(set) var sunset: Date?
    public fileprivate(set) var civilSunrise: Date?
    public fileprivate(set) var civilSunset: Date?
    public fileprivate(set) var strictSunrise: Date?
    public fileprivate(set) var strictSunset: Date?
    public fileprivate(set) var nauticalSunrise: Date?
    public fileprivate(set) var nauticalSunset: Date?
    public fileprivate(set) var astronomicalSunrise: Date?
    public fileprivate(set) var astronomicalSunset: Date?

    // MARK: Init

    public init?(for date: Date = Date(), coordinate: CLLocationCoordinate2D) {
        self.date = date

        guard CLLocationCoordinate2DIsValid(coordinate) else {
            return nil
        }

        self.coordinate = coordinate

        // Fill this Solar object with relevant data
        calculate()
    }

    // MARK: - Public functions

    /// Sets all of the Solar object's sunrise / sunset variables, if possible.
    /// - Note: Can return `nil` objects if sunrise / sunset does not occur on that day.
    public mutating func calculate() {
        strictSunrise = calculate(.sunrise, for: date, and: .strict)
        strictSunset = calculate(.sunset, for: date, and: .strict)
        sunrise = calculate(.sunrise, for: date, and: .official)
        sunset = calculate(.sunset, for: date, and: .official)
        civilSunrise = calculate(.sunrise, for: date, and: .civil)
        civilSunset = calculate(.sunset, for: date, and: .civil)
        nauticalSunrise = calculate(.sunrise, for: date, and: .nautical)
        nauticalSunset = calculate(.sunset, for: date, and: .nautical)
        astronomicalSunrise = calculate(.sunrise, for: date, and: .astronimical)
        astronomicalSunset = calculate(.sunset, for: date, and: .astronimical)
    }

    // MARK: - Private functions

    fileprivate enum SunriseSunset {
        case sunrise
        case sunset
    }

    /// Used for generating several of the possible sunrise / sunset times
    public enum Zenith: Double {
        case strict = 90
        case official = 90.83
        case civil = 96
        case nautical = 102
        case astronimical = 108
    }

    // swiftlint:disable identifier_name
    fileprivate func calculate(_ sunriseSunset: SunriseSunset, for date: Date, and zenith: Zenith) -> Date? {
        guard let utcTimezone = TimeZone(identifier: "UTC") else { return nil }

        // Get the day of the year
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = utcTimezone
        guard let dayInt = calendar.ordinality(of: .day, in: .year, for: date) else { return nil }
        let day = Double(dayInt)

        // Convert longitude to hour value and calculate an approx. time
        let lngHour = coordinate.longitude / 15

        let hourTime: Double = sunriseSunset == .sunrise ? 6 : 18
        let t = day + ((hourTime - lngHour) / 24)

        // Calculate the suns mean anomaly
        let M = (0.9856 * t) - 3.289

        // Calculate the sun's true longitude
        let subexpression1 = 1.916 * sin(M.degreesToRadians)
        let subexpression2 = 0.020 * sin(2 * M.degreesToRadians)
        var L = M + subexpression1 + subexpression2 + 282.634

        // Normalise L into [0, 360] range
        L = normalise(L, withMaximum: 360)

        // Calculate the Sun's right ascension
        var RA = atan(0.91764 * tan(L.degreesToRadians)).radiansToDegrees

        // Normalise RA into [0, 360] range
        RA = normalise(RA, withMaximum: 360)

        // Right ascension value needs to be in the same quadrant as L...
        let Lquadrant = floor(L / 90) * 90
        let RAquadrant = floor(RA / 90) * 90
        RA += (Lquadrant - RAquadrant)

        // Convert RA into hours
        RA /= 15

        // Calculate Sun's declination
        let sinDec = 0.39782 * sin(L.degreesToRadians)
        let cosDec = cos(asin(sinDec))

        // Calculate the Sun's local hour angle
        let cosH = (cos(zenith.rawValue.degreesToRadians) - (sinDec * sin(coordinate.latitude.degreesToRadians))) / (cosDec * cos(coordinate.latitude.degreesToRadians))

        // No sunrise
        guard cosH < 1 else {
            return nil
        }

        // No sunset
        guard cosH > -1 else {
            return nil
        }

        // Finish calculating H and convert into hours
        let tempH = sunriseSunset == .sunrise ? 360 - acos(cosH).radiansToDegrees : acos(cosH).radiansToDegrees
        let H = tempH / 15.0

        // Calculate local mean time of rising
        let T = H + RA - (0.06571 * t) - 6.622

        // Adjust time back to UTC
        var UT = T - lngHour

        // Normalise UT into [0, 24] range
        UT = normalise(UT, withMaximum: 24)

        // Calculate all of the sunrise's / sunset's date components
        let hour = floor(UT)
        let minute = floor((UT - hour) * 60.0)
        let second = (((UT - hour) * 60) - minute) * 60.0

        let shouldBeYesterday = lngHour > 0 && UT > 12 && sunriseSunset == .sunrise
        let shouldBeTomorrow = lngHour < 0 && UT < 12 && sunriseSunset == .sunset

        let setDate: Date
        if shouldBeYesterday {
            setDate = Date(timeInterval: -(60 * 60 * 24), since: date)
        } else if shouldBeTomorrow {
            setDate = Date(timeInterval: (60 * 60 * 24), since: date)
        } else {
            setDate = date
        }

        var components = calendar.dateComponents([.day, .month, .year], from: setDate)
        components.hour = Int(hour)
        components.minute = Int(minute)
        components.second = Int(second)

        calendar.timeZone = utcTimezone
        return calendar.date(from: components)
    }
    // swiftlint:enable identifier_name

    /// Normalises a value between 0 and `maximum`, by adding or subtracting `maximum`
    fileprivate func normalise(_ value: Double, withMaximum maximum: Double) -> Double {
        var value = value

        if value < 0 {
            value += maximum
        }

        if value > maximum {
            value -= maximum
        }

        return value
    }

}

extension Solar {

    /// Whether the location specified by the `latitude` and `longitude` is in daytime on `date`
    /// - Complexity: O(1)
    public var isDaytime: Bool {
        guard
            let sunrise = sunrise,
            let sunset = sunset
            else {
                return false
        }

        let beginningOfDay = sunrise.timeIntervalSince1970
        let endOfDay = sunset.timeIntervalSince1970
        let currentTime = self.date.timeIntervalSince1970

        let isSunriseOrLater = currentTime >= beginningOfDay
        let isBeforeSunset = currentTime < endOfDay

        return isSunriseOrLater && isBeforeSunset
    }

    /// Whether the location specified by the `latitude` and `longitude` is in nighttime on `date`
    /// - Complexity: O(1)
    public var isNighttime: Bool {
        return !isDaytime
    }

    /// Whether the location specified by the `latitude` and `longitude` is in daytime on `date`
    /// Takes an extra Zenith parameter to handle all cases
    /// - Complexity: O(1)
    public func isDaytime(zenith: Zenith) -> Bool {
        guard
            let _ = sunrise,
            let _ = sunset
            else {
                return false
        }

        var lsunrise, lsunset: Date
        switch zenith {
        case .strict:
            lsunrise = strictSunrise!
            lsunset = strictSunset!
        case .civil:
            lsunrise = civilSunrise!
            lsunset = civilSunset!
        case .nautical:
            lsunrise = nauticalSunrise!
            lsunset = nauticalSunset!
        case .astronimical:
            lsunrise = astronomicalSunrise!
            lsunset = astronomicalSunset!
        default:
            lsunrise = sunrise!
            lsunset = sunset!
        }

        let beginningOfDay = lsunrise.timeIntervalSince1970
        let endOfDay = lsunset.timeIntervalSince1970
        let currentTime = self.date.timeIntervalSince1970

        let isSunriseOrLater = currentTime >= beginningOfDay
        let isBeforeSunset = currentTime < endOfDay

        return isSunriseOrLater && isBeforeSunset
    }

    public func getTimeSlice() -> String {
        guard
            let _ = sunrise,
            let _ = sunset
            else {
                return ""
        }

        // We use
        var lsunrise, lsunset: Date

        if astronomicalSunset != nil && astronomicalSunrise != nil {
            lsunrise = astronomicalSunrise!
            lsunset = astronomicalSunset!
        } else if nauticalSunset != nil && nauticalSunrise != nil {
            lsunrise = nauticalSunrise!
            lsunset = nauticalSunset!
        } else if civilSunset != nil && civilSunrise != nil {
            lsunrise = civilSunrise!
            lsunset = civilSunset!
        } else {
            lsunrise = sunrise!
            lsunset = sunset!
        }

        debugLog("lsunrise \(lsunrise) lsunriseEnd \(lsunrise.addingTimeInterval(TimeInterval(PrefsTime.sunEventWindow)))")
        debugLog("psunset \(lsunset.addingTimeInterval(TimeInterval(-PrefsTime.sunEventWindow))) lsunset \(lsunset)")
        debugLog("current \(self.date)")

        if self.date < lsunrise || self.date > lsunset {
            // So this is night, before sunrise, after sunset
            debugLog("night")
            return "night"
        } else if self.date > lsunrise && self.date < lsunrise.addingTimeInterval(TimeInterval(PrefsTime.sunEventWindow)) {
            // Sunrise-period is a 3hr period after astro sunrise
            debugLog("sunrise")
            return "sunrise"
        } else if self.date > lsunset.addingTimeInterval(TimeInterval(-PrefsTime.sunEventWindow)) && self.date < lsunset {
            // Sunset-period is a 3hr period prior astro sunset
            debugLog("sunset")
            return "sunset"
        } else {
            // Let's say this is day
            debugLog("day")
            return "day"
        }
    }
}

// MARK: - Helper extensions

private extension Double {
    var degreesToRadians: Double {
        return Double(self) * (Double.pi / 180.0)
    }

    var radiansToDegrees: Double {
        return (Double(self) * 180.0) / Double.pi
    }
}