open-wether / Sources /App /Helper /Solar /SunRiseSet.swift
soiz1's picture
Migrated from GitHub
6ee917b verified
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<Timestamp>, 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<Timestamp>, 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<Timestamp>, 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)
}
}
}