lib/calculation.ex

defmodule PrayerTimes.Calculation do
  alias PrayerTimes.Utils
  alias PrayerTimes.Methods

  @moduledoc """
  Handles the internal calculations for determining Islamic prayer times.

  This module is primarily used by `PrayerTimes` to calculate accurate prayer times based on various methods and parameters like latitude, longitude, timezone, and date.

  ## Details

  The calculation involves converting the given date to the Julian date, computing the sun's position, and then adjusting the times according to the selected method and high latitude rules.

  This module is not typically used directly in user applications; instead, use `PrayerTimes.compute/1`.

  ## Supported Methods

  - `:mwl`: Muslim World League
  - `:isna`: Islamic Society of North America
  - `:egypt`: Egyptian General Authority of Survey
  - `:makkah`: Umm al-Qura University, Makkah
  - `:karahci`: University of Islamic Sciences, Karachi
  - `:tehran`: Institute of Geophysics, University of Tehran
  - `:jafari`: Shia Ithna-Ashari
  """

  def get_times(%{method: method, timezone: tz, lat: lat, long: long, date: date}) do
    julian_date = Timex.to_julian(date) - long / (15 * 24.0)
    times = compute_times(method, julian_date, tz, lat, long)

    Enum.map(times, fn {key, value} ->
      {hours, minutes} = Utils.float_to_datetime(times[key])

      d = %NaiveDateTime{ year: date.year, month: date.month, day: date.day,
		 minute: trunc(minutes), hour: trunc(hours), second: 0
		  }
      {key, d}
    end)
    |> Enum.into(%{})
  end

  def compute_times(method, jdate, tz, lat, long) do
    times = %{
      imsak: 5,
      fajr: 5,
      sunrise: 6,
      dhuhr: 12,
      asr: 13,
      sunset: 18,
      maghrib: 18,
      isha: 18
    }

    times =
      times
      |> compute_prayer_times(method, jdate, lat)
      |> adjust_times(method, tz, long)

    midnight_diff =
      if Methods.param(method, :midnight) == 'Jafari',
        do: Utils.time_diff(times.sunset, times.fajr),
        else: Utils.time_diff(times.sunset, times.sunrise)

      times
      |> Map.put(:midnight, times.sunset + midnight_diff / 2)
      |> apply_offsets(method)
  end

  def compute_prayer_times(times, method, jdate, lat) do
    times = Enum.map(times, fn {key, value} -> {key, day_portion(value)} end)

    imsak =
      sun_angle_time(jdate, Methods.param(method, %{int: :imsak}), times[:imsak], lat, false)

    fajr = sun_angle_time(jdate, Methods.param(method, %{int: :fajr}), times[:fajr], lat, false)
    sunrise = sun_angle_time(jdate, rise_set_angle(0), times[:sunrise], lat, false)
    dhuhr = mid_day(jdate, times[:dhuhr])
    asr = asr_time(jdate, asr_factor(Methods.param(method, :asr)), times[:asr], lat)
    sunset = sun_angle_time(jdate, rise_set_angle(0), times[:sunset], lat)

    maghrib = sun_angle_time(jdate, Methods.param(method, %{int: :maghrib}), times[:maghrib], lat)

    isha = sun_angle_time(jdate, Methods.param(method, %{int: :isha}), times[:isha], lat)

    %{
      asr: asr,
      imsak: imsak,
      fajr: fajr,
      sunrise: sunrise,
      dhuhr: dhuhr,
      sunset: sunset,
      maghrib: maghrib,
      isha: isha
    }
  end

  def adjust_times(times, method, timezone, long) do
    tz_adjust = timezone - long / 15.0

    times =
      times
      |> Enum.map(fn {key, value} -> {key, value + tz_adjust} end)
      |> Enum.into(%{})

    times =
      if Methods.param(method, :high_lats) != :none,
        do: adjust_high_lats(method, times),
        else: times

    min_imsak = Methods.param(method, %{min: :imsak})
    min_maghrib = Methods.param(method, %{min: :maghrib})
    min_isha = Methods.param(method, %{min: :isha})

    imsak = if min_imsak != nil, do: times.fajr - min_imsak / 60.0, else: times.imsak
    maghrib = if min_maghrib != nil, do: times.sunset + min_maghrib / 60.0, else: times.maghrib
    isha = if min_isha != nil, do: times.maghrib + min_isha / 60.0, else: times.isha
    dhuhr = times.dhuhr + Methods.param(method, %{int: :dhuhr}) / 60.0

    times
    |> Map.put(:imsak, imsak)
    |> Map.put(:maghrib, maghrib)
    |> Map.put(:isha, isha)
    |> Map.put(:dhuhr, dhuhr)
  end

  def adjust_high_lats(method, times) do
    night_time = Utils.time_diff(times[:sunset], times[:sunrise])

    times
    |> Map.put(
      :imsak,
      adjust_high_lats_time(
        method,
        times.imsak,
        times.sunrise,
        Methods.param(method, %{int: :imsak}),
        night_time,
        false
      )
    )
    |> Map.put(
      :fajr,
      adjust_high_lats_time(
        method,
        times[:fajr],
        times[:sunrise],
        Methods.param(method, %{int: :fajr}),
        night_time,
        false
      )
    )
    |> Map.put(
      :isha,
      adjust_high_lats_time(
        method,
        times[:isha],
        times[:sunset],
        Methods.param(method, %{int: :isha}),
        night_time
      )
    )
    |> Map.put(
      :maghrib,
      adjust_high_lats_time(
        method,
        times[:maghrib],
        times[:sunset],
        Methods.param(method, %{int: :maghrib}),
        night_time
      )
    )
  end

  def adjust_high_lats_time(method, time, base, angle, night, clock_wise \\ true) do
    high_lats_method = Methods.param(method, :high_lats)
    portion = night_portion(angle, night, high_lats_method)

    diff = if !clock_wise, do: Utils.time_diff(time, base), else: Utils.time_diff(base, time)

    cond do
      diff > portion ->
        base - portion

      true ->
        time
    end
  end

  def apply_offsets(times, method) do
    times
    |> Enum.map(fn {key, value} -> {key, value + Methods.offset(method, key) / 60.0} end)
    |> Enum.into(%{})
  end

  def night_portion(angle, night, high_lats_method) do
    portion =
      case high_lats_method do
        :angle_based -> 1 / 60.0 * angle
        :one_seventh -> 1 / 7.0
        # Default to midnight
        _ -> 1 / 2.0
      end

    portion * night
  end

  def sun_angle_time(jdate, angle, time, lat, clockwise \\ true) do
    {decl, _} = sun_position(jdate + time)

    noon = mid_day(jdate, time)

    t =
      1 / 15.0 *
        Utils.arccos(
          (-Utils.sin(angle) - Utils.sin(decl) * Utils.sin(lat)) /
            (Utils.cos(decl) * Utils.cos(lat))
        )

    if clockwise == false, do: noon - t, else: noon + t
  end

  def mid_day(jdate, time) do
    jdatetime = jdate + time
    {_, eqt} = sun_position(jdatetime)
    Utils.fixhour(12 - eqt)
  end

  def day_portion(hours) do
    hours / 24.0
  end

  def rise_set_angle(elevation \\ 0) do
    elevation = if elevation == nil, do: 0, else: elevation
    0.833 + 0.0347 * :math.sqrt(elevation)
  end

  def asr_time(jdate, factor, time, lat) do
    {decl, _} = sun_position(jdate + time)
    angle = -Utils.arccot(factor + Utils.tan(abs(lat - decl)))
    sun_angle_time(jdate, angle, time, lat)
  end

  def asr_factor(asr_setting) do
    methods = %{standard: 1, hanafi: 2}
    Map.get(methods, asr_setting, asr_setting)
  end

  def sun_position(jd) do
    d = jd - 2_451_545.0

    g = Utils.fixangle(357.529 + 0.98560028 * d)
    q = Utils.fixangle(280.459 + 0.98564736 * d)
    l = Utils.fixangle(q + 1.915 * Utils.sin(g) + 0.020 * Utils.sin(2 * g))

    # r = 1.00014 - 0.01671 * Utils.cos(g) - 0.00014 * Utils.cos(2 * g)
    e = 23.439 - 0.00000036 * d

    ra = Utils.arctan2(Utils.cos(e) * Utils.sin(l), Utils.cos(l)) / 15.0
    eqt = q / 15.0 - Utils.fixhour(ra)
    decl = Utils.arcsin(Utils.sin(e) * Utils.sin(l))

    {decl, eqt}
  end
end