--[[--
-- Author: Martin Zwicknagl (zwim)
-- Date: 2021-10-29
-- The current source code of this file can be found on https://github.com/zwim/suntime.

Module to calculate ephemeris and other times depending on the sun position.

Maximal errors from 2020-2050 (compared to https://midcdmz.nrel.gov/spa/) are:

* -43.52° Christchurch 66s
* -20.16° Mauritius:   25s
* 20.30° Honolulu:     47s
* 33.58° Casablanca:   24s
* 35.68° Tokio:        50s
* 37.97° Athene:       24s
* 38°    Sacramento:   67s
* 41.91° Rome:         27s
* 47.25° Innsbruck:    13s
* 52.32° Berlin:       30s
* 59.92° Oslo:         42s
* 64.14° Reykjavik:    69s
* 65.69° Akureyri:    <24s (except *)
* 70.67° Hammerfest: <105s (except **)

*) A few days around beginning of summer (error <290s)

**) A few days after and befor midnight sun (error <1200s)

@usage
    local SunTime = require("suntime")

    time_zone = 0
    altitude = 50
    degree = true
    SunTime:setPosition("Reykjavik", 64.14381, -21.92626, timezone, altitude, degree)

    SunTime:setAdvanced()

    SunTime:setDate()

    SunTime:calculateTimes()

    print(SunTime.rise, SunTime.set, SunTime.set_civil) -- or similar see calculateTime()

@module suntime
--]]--

-- math abbrevations
local pi = math.pi
local pi_2 = pi/2

local abs = math.abs
local floor = math.floor
local sin = math.sin
local cos = math.cos
local tan = math.tan
local asin = math.asin
local acos = math.acos
local atan = math.atan

local toRad = pi/180
local toDeg = 1/toRad

local function Rad(x)
    return x*toRad
end

--------------------------------------------
local speed_of_light = 2.99792E8
local sun_radius = 6.96342e8
local average_earth_radius = 6371e3
local semimajor_axis = 149598022.96E3 -- earth orbit's major semi-axis in meter
local average_speed_earth = 29.7859e3
local aberration = asin(average_speed_earth/speed_of_light) -- Aberration relativistic
-- local average_speed_equator = (2*pi * average_earth_radius) / (24*3600)
--------------------------------------------

 -- minimal twillight times in hours
local min_civil_twilight = 20/60
local min_nautic_twilight = 45/60 - min_civil_twilight
local min_astronomic_twilight = 20/60 - min_nautic_twilight

local SunTime = {
        astronomic = Rad(-18),
        nautic =  Rad(-12),
        civil = Rad(-6),
        -- eod = Rad(-49/60), -- approx. end of day
        earth_flatten = 1 / 298.257223563, -- WGS84
        average_temperature = 10, -- °C

        times = {},
    }

----------------------------------------------------------------

-- simple 'Equation of time' good for dates between 2008-2027
-- errors for latitude 20° are within 1min
--                     47° are within 1min 30sec
--                     65° are within 5min
-- https://www.astronomie.info/zeitgleichung/#Auf-_und_Untergang (German)
function SunTime:getZglSimple()
    local T = self.date.yday
    return -0.171 * sin(0.0337 * T + 0.465)  - 0.1299 * sin(0.01787 * T - 0.168)
end

-- more advanced 'Equation of time' good for dates between 1800-2200
-- errors are better than with the simple method
-- https://de.wikipedia.org/wiki/Zeitgleichung (German) and
-- more infos on http://www.hlmths.de/Scilab/Zeitgleichung.pdf (German)
function SunTime:getZglAdvanced()
    local e = self.num_ex
    local e2 = e*e
    local e3 = e2*e
    local e4 = e3*e
    local e5 = e4*e

    -- https://de.wikibooks.org/wiki/Astronomische_Berechnungen_f%C3%BCr_Amateure/_Himmelsmechanik/_Sonne
    local C = (2*e - e3/4 + 5/96*e5) * self.sin_M
            + (5/4*e2 + 11/24*e4) * self.sin_2M
            + (13/12*e3 - 43/64*e5) * self.sin_3M
            + 103/96*e4 * self.sin_4M
            + 1097/960*e5 * self.sin_5M -- rad

    local lamb = self.L + C
    local tanL = tan(self.L)
    local tanLamb = tan(lamb)
    local cosEps = cos(self.epsilon)

    local zgl = atan( (tanL - tanLamb*cosEps) / (1 + tanL*tanLamb*cosEps) ) --rad
    return zgl*toDeg/15 --  to hours *4'/60
end

-- set current date or year/month/day daylightsaving hh/mm/ss
-- if dst == nil use curent daylight saving of the system
local days_in_month = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}

function SunTime:setDate(year, month, day, dst, hour, min, sec)
    self.date = os.date("*t") -- get current day

    if year and month and day then
        self.date.year = year
        self.date.month = month
        self.date.day = day
        if year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then
            days_in_month[2] = 29
        else
            days_in_month[2] = 28
        end
        self.date.yday = day
        for i = 1, month-1 do
            self.date.yday = self.date.yday + days_in_month[i]
        end
        self.date.hour = hour or 12
        self.date.min = min or 0
        self.date.sec = sec or 0
        if dst ~= nil then
            self.date.isdst = dst
        end
    end
end

--[[--
Set position for later calculations

@param name Name of the location
@param latitude Geographical latitude, North is positive
@param longitude Geographical longitude, West is negative
@param time_zone Timezone e.g. CET = +1; if nil try to autodetect the current zone
@param altitude Altitude of the location above the sea level
@param degree if `nil` latitude and longitue are in radian, else in decimal degree
 --]]--
function SunTime:setPosition(name, latitude, longitude, time_zone, altitude, degree)
    altitude = altitude or 200
    if degree then
        latitude = latitude * toRad
        longitude = longitude * toRad
    end

    -- check for sane values
    -- latitudes are from -90° to +90°
    if latitude > pi_2 then
        latitude = pi_2
    elseif latitude < -pi_2 then
        latitude = -pi_2
    end
    -- longitudes are from -180° to +180°
    if longitude > pi then
        longitude = pi
    elseif longitude < -pi then
        longitude = -pi
    end

    latitude = atan((1-self.earth_flatten)^2 * tan(latitude))

    self.pos = {name = name, latitude = latitude, longitude = longitude, altitude = altitude}
    self.time_zone = time_zone or self:getTimezoneOffset()
--    self.refract = Rad(36.35/60 * .5 ^ (altitude / 5538)) -- constant temperature
    self.refract = Rad(36.20/60 * (1 - 0.0065*altitude/(273.15+self.average_temperature)) ^ 5.255 )

    self.sin_latitude = sin(self.pos.latitude)
    self.cos_latitude = cos(self.pos.latitude)
end

--[[--
  Use a simple equation of time (valid for the years 2008-2027)
--]]--
function SunTime:setSimple()
    self.getZgl = self.getZglSimple
end
--[[--
  Use an advanced equation of time (valid for the years 1800-2200 at least)
--]]--
function SunTime:setAdvanced()
    self.getZgl = self.getZglAdvanced
end

--[[--
  Function to get the equation of time, can be set by setSimple() or setAdvanced()
--]]--
SunTime.getZgl = SunTime.getZglAdvanced

function SunTime:daysSince2000(hour)
    local delta = self.date.year - 2000
    local leap = floor((delta-1)/4)
    return 365 * delta + leap + self.date.yday + (hour-12)/24   -- WMO No.8, rebased for 2000-01-01 12:00
end

-- more accurate parameters of earth orbit from
-- Title: Numerical expressions for precession formulae and mean elements for the Moon and the planets
-- Authors: Simon, J. L., Bretagnon, P., Chapront, J., Chapront-Touze, M., Francou, G., & Laskar, J., ,
-- Journal: Astronomy and Astrophysics (ISSN 0004-6361), vol. 282, no. 2, p. 663-683
-- Bibliographic Code: 1994A&A...282..663S
function SunTime:initVars(hour)
    if not hour then
        hour = 12
    end

    local after_noon = hour > 12

    local T = self:daysSince2000(hour)/36525 -- in Julian centuries form 2000-01-01 12:00

    --    self.num_ex = 0.0167086342 - 0.000042 * T
    -- numerical eccentricity of earth's orbit
    -- see wikipedia: https://de.wikipedia.org/wiki/Erdbahn-> Meeus
    --    and Numerical expressions for preccession formulae
    -- time is in Julian centuries
    self.num_ex =     0.0167086342     + T*(-0.0004203654e-1
                + T*(-0.0000126734e-2  + T*( 0.0000001444e-3
                + T*(-0.0000000002e-4  + T*  0.0000000003e-5))))

    --    self.epsilon = (23 + 26/60 + 21/3600 - 46.82/3600 * T) * toRad
    -- earth's obliquity to the ecliptic
    -- see Numerical expressions for precession formulae ...
    -- Time is here in Julian centuries
--    local epsilon = 23 + 26/60  + (21.412 + T*(-468.0927E-1
--                + T*(-0.0152E-2 + T*(1.9989E-3
--                + T*(-0.0051E-4 - T*0.0025E-5)))))/3600 --°

    -- Astronomical Almanac 2010, p. B52
    -- Time is here in Julian centuries
    local epsilon = 23 + 26/60 + (21.406 - T*(46.836769
                  - T*( 0.0001831 + T*(0.00200340
                  + T*(-5.76E-7   - T* 4.34E-8)))))/3600 --°

    self.epsilon = epsilon * toRad

    -- see Numerical expressions for precession formulae ...
    -- mean longitude
    local nT = T * (36000.7690/35999.3720) -- convert from equinox to date
    local L =  100.46645683   + (nT*(1295977422.83429E-1
            + nT*(-2.04411E-2 -  nT* 0.00523E-3)))/3600 --°
    self.L = (L - floor(L/360)*360) * toRad

    -- see Numerical expressions for precession formulae ...
    -- Time is here in Julian centuries
    local omega =     102.93734808 + nT*(11612.35290e-1
                + nT*(53.27577e-2  + nT*(-0.14095e-3
                + nT*( 0.11440e-4  + nT* 0.00478e-5))))/3600 --°

    -- mean anomaly
    local M = L - omega --°
    self.M = (M - floor(M/360)*360) * toRad

    self.sin_M = sin(self.M)
    self.cos_M = cos(self.M)

    -- sin(2x)=2 sin(x) cos(x)
    self.sin_2M = 2 * self.sin_M * self.cos_M

    -- sin(3x) = 3 sin(x) − 4 sin(x)^3
    self.sin_3M = 3 * self.sin_M - 4 * self.sin_M^3

    -- sin(4x) = 8 sin(x) cos(x)^3 - 4 sin(x) cos(x)
    self.sin_4M = 8 * self.sin_M * self.cos_M^3 - 4 * self.sin_M * self.cos_M

    -- sin(5x) = 5 sin(x) - 20 sin(x)^3+ 16 sin(x)^5
    self.sin_5M = 5 * self.sin_M - 20 * self.sin_M^3 + 16 * self.sin_M^5

    -- Deklination nach astronomie.info
    --  local decl = 0.4095 * sin(0.016906 * (self.date.yday - 80.086))
    --Deklination nach Brodbeck (2001)
    --  local decl = 0.40954 * sin(0.0172 * (self.date.yday - 79.349740))

    -- Deklination WMO-No.8 page I-7-37
    --local T = self.days_since_2000
    --local L = 280.460 + 0.9856474 * T
    --L = (L - floor(L/360)*360) * toRad
    --local g = 357.528 + 0.9856003 * T -- mean anomaly
    --g = (g - floor(g/360)*360) * toRad
    --local l =  L + (1.915 * sin (g) + 0.020 * sin (2*g))*toRad
    --local ep = self.epsilon
    --  -- sin(decl) = sin(ep)*sin(l)
    --self.decl = asin(sin(ep)*sin(l))

    -- Deklination WMO-No.8 page I-7-37
    local l =  self.L + pi + (1.915 * self.sin_M + 0.020 * self.sin_2M)*toRad
    self.decl = asin(sin(self.epsilon)*sin(l))

    -- Nutation see https://de.wikipedia.org/wiki/Nutation_(Astronomie)
    local A = { 2.18243920 - 33.7570460 * T,
               -2.77624462 + 1256.66393 * T,
                7.62068856 + 16799.4182 * T,
                4.36487839 - 67.5140919 * T}
    local B = {92025e-4 + 8.9e-4 * T,
                5736e-4 - 3.1e-4 * T,
                 977e-4 - 0.5e-4 * T,
                -895e-4 + 0.5e-4 * T}
    local delta_epsilon = 0 --"
    for i = 1, #A do
        delta_epsilon = delta_epsilon + B[i]*cos(A[i])
    end

    -- add nutation to declination
    self.decl = self.decl + delta_epsilon/3600*toRad

    -- https://de.wikipedia.org/wiki/Kepler-Gleichung#Wahre_Anomalie
    self.E = self.M + self.num_ex * self.sin_M + self.num_ex^2 / 2 * self.sin_2M
    self.r = semimajor_axis * (1 - self.num_ex * cos(self.E))

    --    self.eod = -atan(sun_radius/self.r) - self.refract
    --                                            ^- astronomical refraction (at altitude)

    if after_noon then
        self.eod = -atan((sun_radius-average_earth_radius*self.cos_latitude)/self.r) - self.refract
        self.eod = self.eod + aberration
    else
        self.eod = -atan((sun_radius+average_earth_radius*self.cos_latitude)/self.r) - self.refract
        self.eod = self.eod - aberration
    end

    self.zgl = self:getZgl()
end

function SunTime:getTimeDiff(height)
    local val = (sin(height) - self.sin_latitude*sin(self.decl))
                / (self.cos_latitude*cos(self.decl))

    if abs(val) > 1 then
        return
    end
    return 12/pi * acos(val)
end

-- get the sun height for a given time
-- eod for considering sun diameter and astronomic refraction
function SunTime:getHeight(time, eod)
    time = time - 12 -- subtrace 12, because JD starts at 12:00
    local val = cos(self.decl)*self.cos_latitude*cos(pi/12*time)
        + sin(self.decl)*self.sin_latitude

    if abs(val) > 1 then
        return
    end

    if eod then
        return asin(val) - eod -- self.eod might be a bit too small
    else
        return asin(val)
    end
end

-- Get time for a certain height
-- Set height to nil for sunset/rise
-- Set hour near to expected time
-- Set after_noon to true, if sunset is wanted
-- Set no_correct_dst if no daylight saving correction is wanted
-- Result rise or set time
--        nil sun does not reach the height
function SunTime:calculateTime(height, hour, after_noon, no_correct_dst)
    if not no_correct_dst then
        if self.date.isdst and hour then
            hour = hour - 1
        end
    end
    self:initVars(hour) -- calculate self.eod
    local timeDiff = self:getTimeDiff(height or self.eod, hour)
    if not timeDiff then
        return
    end

    local local_correction = self.time_zone - self.pos.longitude*12/pi - self.zgl
    if not after_noon then
        hour = 12 - timeDiff + local_correction
    else
        hour = 12 + timeDiff + local_correction
    end
    if not no_correct_dst then
        if self.date.isdst and hour then
            hour = hour + 1
        end
    end
    return hour
end

-- Calculates the hour, when the sun reaches height
-- If height is nil, use newly calculated self.eod
-- hour gives a start value, default is used when hour == nil
function SunTime:calculateTimeIter(height, hour, default_hour)
    local after_noon = (hour and hour > 12) or (default_hour and default_hour > 12)

    if not hour then -- do the iteration with the default value
        hour = self:calculateTime(height, default_hour, after_noon, true)
    elseif hour and not default_hour then -- do the full iteration with value
        hour = self:calculateTime(height, hour, after_noon, true)
    end -- if hour and default_hour are given don't do the first step

    if hour ~= nil then -- do the last calculation step
        hour = self:calculateTime(height, hour, hour > 12)
    end
    return hour
end

function SunTime:calculateNoon(hour)
    hour = hour or 12
    self:initVars(hour)
    local aberration_time = aberration / pi * 12 -- aberration in hours (angle/(2pi)*24)
    local dst = self.date.isdst and 1 or 0
    local local_correction = self.time_zone - self.pos.longitude*12/pi + dst - self.zgl
    if self.pos.latitude >= 0 then -- northern hemisphere
        if pi_2 - self.pos.latitude + self.decl > self.eod then
            if self:getHeight(hour) > 0 then
                return hour + local_correction + aberration_time
            end
        end
    else -- sourthern hemisphere
        if pi_2 + self.pos.latitude - self.decl > self.eod then
            if self:getHeight(hour) > 0 then
                return hour + local_correction + aberration_time
            end
        end
    end
end

function SunTime:calculateMidnight(hour)
    -- hour:
    -- 00 would be the beginning of the day
    -- 24 is the midnight at the end of the current day,
    hour = hour or 24
    self:initVars(hour)
    local dst = self.date.isdst and 1 or 0
    -- no aberration correction here, as you can't see the sun on her nadir ;-)
    local local_correction = self.time_zone - self.pos.longitude*12/pi + dst - self.zgl
    if self.pos.latitude >= 0 then -- northern hemisphere
        if pi_2 - self.pos.latitude - self.decl > self.eod then
            if self:getHeight(hour) < 0 then
                return hour + local_correction
            end
        end
    else -- sourthern hemisphere
        if pi_2 + self.pos.latitude + self.decl > self.eod then
            if self:getHeight(hour) < 0 then
                return hour + local_correction
            end
        end
    end
end

--[[--
Calculates the ephemeris and twilight times

@param fast_twilight If not nil, then exact twilight times will be calculated.

@usage
SunTime:calculateTimes(fast_twilight)


Times are in hours or `nil` if not applicable.

You can then access:
    self.midnight_beginning

    self.rise_astronomic
    self.rise_nautic
    self.rise_civil
    self.rise

    self.noon

    self.set
    self.set_civil
    self.set_nautic
    self.set_astronomic

    self.midnight

Or as values in a table:
    self.times[1]  midnight_beginning
    self.times[2]  rise_astronomic
    self.times[3]  rise_nautic
    self.times[4]  rise_civil
    self.times[5]  rise
    self.times[6]  noon
    self.times[7]  set
    self.times[8]  set_civil
    self.times[9]  set_nautic
    self.times[10] set_astronomic
    self.times[11] midnight
--]]--
function SunTime:calculateTimes(fast_twilight)
    -- All or some the times can be nil at great latitudes
    -- but either noon or midnight is not nil

    if not fast_twilight then
    -- The canonical way is to calculate everything from scratch
        self.rise = self:calculateTimeIter(nil, 6)
        self.set = self:calculateTimeIter(nil, 18)

        self.rise_civil = self:calculateTimeIter(self.civil, 6)
        self.set_civil = self:calculateTimeIter(self.civil, 18)
        self.rise_nautic = self:calculateTimeIter(self.nautic, 6)
        self.set_nautic = self:calculateTimeIter(self.nautic, 18)
        self.rise_astronomic = self:calculateTimeIter(self.astronomic, 6)
        self.set_astronomic = self:calculateTimeIter(self.astronomic, 18)
    else
        -- Calculate rise and set from scratch, use these values for twilight times
        self.rise = self:calculateTimeIter(nil, 6)
        self.rise_civil = self:calculateTimeIter(self.civil, self.rise - min_civil_twilight, 6)
        self.rise_nautic = self:calculateTimeIter(self.nautic, self.rise_civil - min_nautic_twilight, 6)
        self.rise_astronomic = self:calculateTimeIter(self.astronomic, self.rise_nautic - min_astronomic_twilight, 6)

        self.set = self:calculateTimeIter(nil, 18)
        self.set_civil = self:calculateTimeIter(self.civil, self.set + min_civil_twilight, 18)
        self.set_nautic = self:calculateTimeIter(self.nautic, self.set_civil + min_nautic_twilight, 18)
        self.set_astronomic = self:calculateTimeIter(self.astronomic, self.set_nautic + min_astronomic_twilight, 18)
    end

    self.midnight_beginning = self:calculateMidnight(0)
    self.noon = self:calculateNoon()
    self.midnight = self:calculateMidnight()

    -- Sometimes at high latitudes noon or midnight does not get calculated.
    -- Maybe there is a minor bug in the calculateNoon/calculateMidnight functions.
    if self.rise and self.set then
        if not self.noon and self.rise and self.set then
            self.noon = (self.rise + self.set) / 2
        end
        if not self.midnight and self.noon then
            self.midnight = self.noon + 12
        end
        if not self.midnight_beginning and self.midnight then
            self.midnight_beginning = self.midnight - 24
        elseif not self.midnight and self.midnight_beginning then
            self.midnight = self.midnight_beginning + 24
        end
    elseif self.rise and not self.set then -- only sunrise on that day
        self.midnight = nil
        self.midnight_beginning = nil
    elseif self.set and not self.rise then -- only sunset on that day
        self.noon = nil
    end

    self.times[1]  = self.midnight_beginning
    self.times[2]  = self.rise_astronomic
    self.times[3]  = self.rise_nautic
    self.times[4]  = self.rise_civil
    self.times[5]  = self.rise
    self.times[6]  = self.noon
    self.times[7]  = self.set
    self.times[8]  = self.set_civil
    self.times[9]  = self.set_nautic
    self.times[10] = self.set_astronomic
    self.times[11] = self.midnight
end

-- Get time in seconds, rounded to ms (either actual time in hours or date struct)
function SunTime:getTimeInSec(val)
    if not val then
        val = os.date("*t")
    end

    if type(val) == "table" then
        val = val.hour*3600 + val.min*60 + val.sec
    else
        val = val*3600
    end
    return math.floor(val * 1000) * (1/1000)
end

-- Get the timezone offset in hours (including dst).
 function SunTime:getTimezoneOffset()
    local now_ts = os.time()
    local utcdate   = os.date("!*t", now_ts)
    local localdate = os.date("*t", now_ts)
    return os.difftime(os.time(localdate), os.time(utcdate)) * (1/3600)
end

return SunTime
