import Foundation extension Zensun { /// Calculate sun rise and set times /// It is assumed the UTC offset has been applied already to `timeRange`. It will be removed in the next step public static func calculateSunRiseSet(timeRange: Range, lat: Float, lon: Float, utcOffsetSeconds: Int) -> (rise: [Timestamp], set: [Timestamp]) { var rises = [Timestamp]() var sets = [Timestamp]() let nDays = (timeRange.upperBound.timeIntervalSince1970 - timeRange.lowerBound.timeIntervalSince1970) / 86400 rises.reserveCapacity(nDays) sets.reserveCapacity(nDays) for time in timeRange.stride(dtSeconds: 86400) { let utc = time.add(utcOffsetSeconds) switch calculateSunTransit(utcMidnight: utc, lat: lat, lon: lon) { case .polarNight: rises.append(time) sets.append(time) case .polarDay: rises.append(time) sets.append(time.add(24*3600)) case .transit(rise: let rise, set: let set): rises.append(utc.add(rise)) sets.append(utc.add(set)) } } assert(rises.count == nDays) assert(sets.count == nDays) return (rises, sets) } /// Calculate daylight duration in seconds /// Time MUST be 0 UTC, it will add the time to match the noon time based on longitude /// The correct time is important to get the correct sun declination at local noon public static func calculateDaylightDuration(utcMidnight: Range, lat: Float, lon: Float) -> [Float] { let utcOffsetApproximated = Int((-lon/15)*3600) return calculateDaylightDuration(localMidnight: utcMidnight.add(utcOffsetApproximated), lat: lat) } /// Calculate daylight duration. `localMidnight` should be be aligned to 0:00 localtime. E.g. 22 UTC for CEST. public static func calculateDaylightDuration(localMidnight: Range, lat: Float) -> [Float] { return localMidnight.stride(dtSeconds: 86400).map { date in let t1 = date.add(12*3600).getSunDeclination().degreesToRadians let alpha = Float(0.83333).degreesToRadians let t0 = lat.degreesToRadians let arg = -(sin(alpha)+sin(t0)*sin(t1))/(cos(t0)*cos(t1)) guard arg <= 1 && arg >= -1 else { // polar night or day return arg > 1 ? 0 : 24*3600 } let dtime = acos(arg)/(Float(15).degreesToRadians) return dtime * 2 * 3600 } } public enum SunTransit { case polarNight case polarDay /// Seconds after midnight in local time! case transit(rise: Int, set: Int) } /// Time MUST be 0 UTC, it will add the time to match the noon time based on longitude /// The correct time is important to get the correct sun declination at local noon @inlinable static func calculateSunTransit(utcMidnight: Timestamp, lat: Float, lon: Float) -> SunTransit { let localMidday = utcMidnight.add(Int((12-lon/15)*3600)) let eqtime = localMidday.getSunEquationOfTime() let t1 = localMidday.getSunDeclination().degreesToRadians let alpha = Float(0.83333).degreesToRadians let noon = 12-lon/15 let t0 = lat.degreesToRadians let arg = -(sin(alpha)+sin(t0)*sin(t1))/(cos(t0)*cos(t1)) guard arg <= 1 && arg >= -1 else { return arg > 1 ? .polarNight : .polarDay } let dtime = acos(arg)/(Float(15).degreesToRadians) let sunrise = noon-dtime-eqtime let sunset = noon+dtime-eqtime return .transit(rise: Int(sunrise*3600), set: Int(sunset*3600)) } /// Calculate if a given timestep has daylight (`1`) or not (`0`) using sun transit calculation public static func calculateIsDay(timeRange: TimerangeDt, lat: Float, lon: Float) -> [Float] { let universalUtcOffsetSeconds = Int(lon/15 * 3600) var lastCalculatedTransit: (date: Timestamp, transit: SunTransit)? = nil return timeRange.map({ time -> Float in // As we iteratate over an hourly range, caculate local-time midnight night for the given timestamp let localMidnight = time.add(universalUtcOffsetSeconds).floor(toNearest: 24*3600).add(-1 * universalUtcOffsetSeconds) // calculate new transit if required if lastCalculatedTransit?.date != localMidnight { lastCalculatedTransit = (localMidnight, calculateSunTransit(utcMidnight: localMidnight, lat: lat, lon: lon)) } guard let lastCalculatedTransit else { fatalError("Not possible") } switch lastCalculatedTransit.transit { case .polarNight: return 0 case .polarDay: return 1 case .transit(rise: let rise, set: let set): // Compare in local time let secondsSinceMidnight = time.add(universalUtcOffsetSeconds).secondsSinceMidnight return secondsSinceMidnight > (rise+universalUtcOffsetSeconds) && secondsSinceMidnight < (set+universalUtcOffsetSeconds) ? 1 : 0 } }) } /// Approximate daylight duration (DNI > 120 w/m2) in seconds. `directRadiation` must be backwards averaged over dt. /// Assumes a linear distribution over 60-180 watts instead of a hard cut of 120 watts. /// Timeinterval `dt`is adjusted to sunrise and sunset to ensure. Only considering DNI will lead to sunshine greated than daylight duration. public static func calculateBackwardsSunshineDuration(directRadiation: [Float], latitude: Float, longitude: Float, timerange: TimerangeDt) -> [Float] { let dt = Float(timerange.dtSeconds) return zip(directRadiation, timerange).map { (dhi, timestamp) in if dhi.isNaN { return .nan } if dhi <= 0 { return 0 } /// DNI is typically limted to 85° zenith. We apply 5° to the parallax in addition to atmospheric refraction /// The parallax is then use to limit integral coefficients to sun rise/set let alpha = Float(0.83333 - 5).degreesToRadians let decang = timestamp.getSunDeclination() let eqtime = timestamp.getSunEquationOfTime() let latsun=decang /// universal time let ut = timestamp.hourWithFraction let t1 = (90-latsun).degreesToRadians let lonsun = -15.0*(ut-12.0+eqtime) /// longitude of sun let p1 = lonsun.degreesToRadians let ut0 = ut - (Float(timerange.dtSeconds)/3600) let lonsun0 = -15.0*(ut0-12.0+eqtime) let p10 = lonsun0.degreesToRadians let t0=(90-latitude).degreesToRadians /// longitude of point var p0 = longitude.degreesToRadians if p0 < p1 - .pi { p0 += 2 * .pi } if p0 > p1 + .pi { p0 -= 2 * .pi } // limit p1 and p10 to sunrise/set let arg = -(sin(alpha)+cos(t0)*cos(t1))/(sin(t0)*sin(t1)) let carg = arg > 1 || arg < -1 ? .pi : acos(arg) let sunrise = p0 + carg let sunset = p0 - carg let p1_l = min(sunrise, p10) let p10_l = max(sunset, p1) // limit dt to sunrise/set let dtBound = dt * abs((p1_l - p10_l) / (p10 - p1)) // solve integral to get sun elevation dt // integral(cos(t0) cos(t1) + sin(t0) sin(t1) cos(p - p0)) dp = sin(t0) sin(t1) sin(p - p0) + p cos(t0) cos(t1) + constant let left = sin(t0) * sin(t1) * sin(p1_l - p0) + p1_l * cos(t0) * cos(t1) let right = sin(t0) * sin(t1) * sin(p10_l - p0) + p10_l * cos(t0) * cos(t1) let zzBackwards = (left-right) / (p1_l - p10_l) let dni = dhi / zzBackwards // Prevent possible division by zero // See https://github.com/open-meteo/open-meteo/discussions/395 let dniBounded = zzBackwards <= 0.0001 ? dhi : dni // >120 watts would be a "hard-cut" and not realistic as data is averaged over 1 hours. Instead, linearly interpolate between 60 and 180 watts. return min(max(dniBounded - 60, 0) / (180 - 60) * dtBound, dtBound) } } }