|
|
package diyanetcalc
|
|
|
|
|
|
import (
|
|
|
"context"
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
"math"
|
|
|
"time"
|
|
|
|
|
|
"prayertimes/pkg/prayer"
|
|
|
)
|
|
|
|
|
|
const (
|
|
|
daysToGenerate = 30
|
|
|
degPerHour = 15.0
|
|
|
)
|
|
|
|
|
|
var errNotSupported = errors.New("not supported in calculation provider")
|
|
|
|
|
|
// Diyanet prayer times calculator.
|
|
|
//
|
|
|
// Implements the Turkish Presidency of Religious Affairs (Diyanet) methodology,
|
|
|
// standardized in the 1983 reform. Prayer times are indexed to the Sun's apparent
|
|
|
// altitude angle at the observer's location, solved via spherical trigonometry.
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Diyanet angular criteria (post-1983 reform)
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Imsak (Fajr): Sun is 18deg below the horizon = start of astronomical twilight.
|
|
|
// Pre-1983 used -19deg plus a temkin buffer; now -18deg with zero temkin.
|
|
|
const imsakAngle = -18.0
|
|
|
|
|
|
// Isha (Yatsi): Sun is 17deg below the horizon = shafaq al-ahmar (red twilight)
|
|
|
// has fully disappeared from the western sky.
|
|
|
const ishaAngle = -17.0
|
|
|
|
|
|
// Sunrise / Sunset (Tulu / Gurup): geometric horizon alone is insufficient.
|
|
|
// Two physical corrections are combined into a single -0.833deg value (50 arcmin):
|
|
|
// - Atmospheric refraction: ~0.567deg — air bends sunlight over the horizon
|
|
|
// - Solar semi-diameter: ~0.267deg — Sun is "up" when its upper limb clears
|
|
|
const sunAngle = -0.833
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Temkin — precautionary time buffers (minutes, post-1983 standardized values)
|
|
|
//
|
|
|
// Temkin ensures a single published time remains valid across the full
|
|
|
// geographical extent of a city (highest peak to lowest valley, east to west).
|
|
|
// Pre-1983 values were often 10–20 min; the reform moderated them.
|
|
|
//
|
|
|
// Imsak: 0 min — no buffer; avoids starting Fajr too early / fast too late
|
|
|
// Sunrise: -7 min — subtracted, ensuring the Sun has fully cleared the horizon
|
|
|
// Dhuhr: +5 min — added, ensuring the Sun has clearly begun its descent
|
|
|
// Asr: +4 min — accounts for local elevation and horizon obstacles
|
|
|
// Maghrib:+7 min — ensures the Sun has completely set before breaking fast
|
|
|
// Isha: 0 min — no buffer needed at this twilight stage
|
|
|
//
|
|
|
// ---------------------------------------------------------------------------
|
|
|
type temkinMinutes struct {
|
|
|
Imsak float64
|
|
|
Sunrise float64
|
|
|
Dhuhr float64
|
|
|
Asr float64
|
|
|
Maghrib float64
|
|
|
Isha float64
|
|
|
}
|
|
|
|
|
|
var temkin = temkinMinutes{
|
|
|
Imsak: 0,
|
|
|
Sunrise: -7,
|
|
|
Dhuhr: 5,
|
|
|
Asr: 4,
|
|
|
Maghrib: 7,
|
|
|
Isha: 0,
|
|
|
}
|
|
|
|
|
|
type Provider struct{}
|
|
|
|
|
|
func New() Provider {
|
|
|
return Provider{}
|
|
|
}
|
|
|
|
|
|
func (Provider) SearchLocations(_ context.Context, _ string) ([]prayer.Location, error) {
|
|
|
return nil, fmt.Errorf("failed to search locations: %w", errNotSupported)
|
|
|
}
|
|
|
|
|
|
func (Provider) Get(_ context.Context, _ string) ([]prayer.Times, error) {
|
|
|
return nil, fmt.Errorf("failed to get prayer times by location id: %w", errNotSupported)
|
|
|
}
|
|
|
|
|
|
func (Provider) GetByCoords(_ context.Context, coords prayer.Coordinates) ([]prayer.Times, error) {
|
|
|
offset := estimateUTCOffsetHours(coords.Longitude)
|
|
|
todayUTC := time.Now().UTC().Truncate(24 * time.Hour)
|
|
|
|
|
|
results := make([]prayer.Times, 0, daysToGenerate)
|
|
|
for i := 0; i < daysToGenerate; i++ {
|
|
|
day := todayUTC.AddDate(0, 0, i)
|
|
|
calculated := prayerTimes(coords.Latitude, coords.Longitude, day)
|
|
|
|
|
|
results = append(results, prayer.Times{
|
|
|
Date: day.Format(time.DateOnly),
|
|
|
Fajr: formatHHMM(calculated.Imsak, offset),
|
|
|
Sunrise: formatHHMM(calculated.Sunrise, offset),
|
|
|
Dhuhr: formatHHMM(calculated.Dhuhr, offset),
|
|
|
Asr: formatHHMM(calculated.Asr, offset),
|
|
|
Sunset: formatHHMM(calculated.Sunset, offset),
|
|
|
Maghrib: formatHHMM(calculated.Maghrib, offset),
|
|
|
Isha: formatHHMM(calculated.Isha, offset),
|
|
|
})
|
|
|
}
|
|
|
|
|
|
return results, nil
|
|
|
}
|
|
|
|
|
|
// Convert a calendar date to a Julian Day Number.
|
|
|
//
|
|
|
// JDN is a continuous day count from Jan 1, 4713 BC used in astronomy to
|
|
|
// avoid calendar-system ambiguities. The -1524.5 offset shifts the epoch to
|
|
|
// noon UT, Jan 1, 4713 BC (the standard astronomical Julian Date epoch).
|
|
|
// The Gregorian calendar correction term b = 2 - a + a//4 accounts for
|
|
|
// the century-year leap-day rules introduced in 1582.
|
|
|
func julianDay(d time.Time) float64 {
|
|
|
y, m, day := d.Date()
|
|
|
if m <= 2 {
|
|
|
// January/February treated as months 13/14 of the prior year
|
|
|
y--
|
|
|
m += 12
|
|
|
}
|
|
|
a := y / 100
|
|
|
b := 2 - a + a/4 // Gregorian correction
|
|
|
|
|
|
return math.Floor(365.25*float64(y+4716)) + math.Floor(30.6001*float64(m+1)) + float64(day+b) - 1524.5
|
|
|
}
|
|
|
|
|
|
// Compute solar declination and equation of time for a given Julian Day.
|
|
|
//
|
|
|
// Returns:
|
|
|
//
|
|
|
// delta — solar declination in degrees: the Sun's angular distance
|
|
|
// north (+) or south (-) of the celestial equator. Controls
|
|
|
// seasonal day length and the Sun's maximum altitude.
|
|
|
// eot — equation of time in minutes: difference between apparent solar
|
|
|
// time (sundial) and mean solar time (clock). Caused by Earth's
|
|
|
// elliptical orbit and axial tilt; ranges roughly ±16 min.
|
|
|
//
|
|
|
// Algorithm uses low-precision USNO solar coordinates (~0.01deg accuracy):
|
|
|
//
|
|
|
// d — days since J2000.0 epoch (Jan 1.5, 2000 = JD 2451545.0)
|
|
|
// g — mean anomaly: Sun's angular position in its elliptical orbit
|
|
|
// q — mean longitude of the Sun
|
|
|
// L — ecliptic longitude: corrected for orbital eccentricity via the
|
|
|
// equation of centre (1.915deg*sin g + 0.020deg*sin 2g)
|
|
|
// e — obliquity of the ecliptic: Earth's axial tilt (~23.44deg, slowly
|
|
|
// decreasing at 0.00000036deg/day)
|
|
|
func sunParams(jd float64) (delta float64, eot float64) {
|
|
|
d := jd - 2451545.0 // days since J2000.0
|
|
|
|
|
|
g := toRadians(357.529 + 0.98560028*d) // mean anomaly
|
|
|
q := toRadians(280.459 + 0.98564736*d) // mean longitude
|
|
|
|
|
|
// Ecliptic longitude: equation of centre adds up to ~1.9deg correction for
|
|
|
// the difference between uniform circular and actual elliptical motion.
|
|
|
L := q + toRadians(1.915*math.Sin(g)+0.020*math.Sin(2*g))
|
|
|
e := toRadians(23.439 - 0.00000036*d) // obliquity of ecliptic
|
|
|
|
|
|
// Declination: project ecliptic longitude onto the celestial equator.
|
|
|
delta = toDegrees(math.Asin(math.Sin(e) * math.Sin(L)))
|
|
|
|
|
|
// Right ascension in hours (atan2 handles all four quadrants correctly).
|
|
|
ra := toDegrees(math.Atan2(math.Cos(e)*math.Sin(L), math.Cos(L))) / degPerHour
|
|
|
|
|
|
// EoT = mean sun hour angle minus apparent sun RA, normalized to ±30 min.
|
|
|
// round() removes the large integer offset (q accumulates many full rotations)
|
|
|
// before converting to minutes; without it the raw difference is ~600 hours.
|
|
|
diff := toDegrees(q)/degPerHour - ra
|
|
|
eot = (diff - math.Round(diff)) * 60
|
|
|
|
|
|
return delta, eot
|
|
|
}
|
|
|
|
|
|
// Solve for the hour angle H (hours) at which the Sun reaches a given altitude.
|
|
|
//
|
|
|
// Derived from the spherical law of cosines for the astronomical triangle:
|
|
|
//
|
|
|
// sin(a) = sin(phi)*sin(delta) + cos(phi)*cos(delta)*cos(H)
|
|
|
//
|
|
|
// Rearranged:
|
|
|
//
|
|
|
// cos(H) = (sin(a) − sin(phi)*sin(delta)) / (cos(phi)*cos(delta))
|
|
|
//
|
|
|
// H is converted from degrees to hours by dividing by 15 (360deg/24h = 15deg/h).
|
|
|
// Returns false when |cos H| > 1, i.e. the Sun never reaches that altitude
|
|
|
// (midnight sun or polar night) — Diyanet handles these with the Takdir method.
|
|
|
func hourAngle(altitudeDeg, lat, delta float64) (hours float64, ok bool) {
|
|
|
cosH := (math.Sin(toRadians(altitudeDeg)) - math.Sin(toRadians(lat))*math.Sin(toRadians(delta))) /
|
|
|
(math.Cos(toRadians(lat)) * math.Cos(toRadians(delta)))
|
|
|
|
|
|
if math.Abs(cosH) > 1 {
|
|
|
return 0, false
|
|
|
}
|
|
|
|
|
|
return toDegrees(math.Acos(cosH)) / degPerHour, true
|
|
|
}
|
|
|
|
|
|
// Compute the solar altitude at which Asr begins (Asr-i Avval / First Asr).
|
|
|
//
|
|
|
// Diyanet follows the majority-school definition: Asr starts when an object's
|
|
|
// shadow length equals the object's height plus its shortest noon shadow (fey-i zeval).
|
|
|
// The shadow factor is 1 (Asr-i Avval; Hanafi uses 2 for Asr-i Sani).
|
|
|
//
|
|
|
// The required altitude satisfies: cot(a) = 1 + tan(|phi − delta|)
|
|
|
// which is: a = atan(1 / (1 + tan(|phi − delta|)))
|
|
|
// where |phi − delta| is the Sun's angular distance from the zenith at solar noon.
|
|
|
func asrAltitude(lat, delta float64) float64 {
|
|
|
return toDegrees(math.Atan(1.0 / (1.0 + math.Tan(toRadians(math.Abs(lat-delta))))))
|
|
|
}
|
|
|
|
|
|
// Convert a decimal hour value (e.g. 10.5 = 10:30) to a UTC-aware datetime.
|
|
|
func decimalHoursToUTC(hours float64, d time.Time) time.Time {
|
|
|
dayUTC := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
return dayUTC.Add(time.Duration(hours * float64(time.Hour)))
|
|
|
}
|
|
|
|
|
|
type computedTimes struct {
|
|
|
Imsak *time.Time
|
|
|
Sunrise *time.Time
|
|
|
Dhuhr *time.Time
|
|
|
Asr *time.Time
|
|
|
Sunset *time.Time
|
|
|
Maghrib *time.Time
|
|
|
Isha *time.Time
|
|
|
}
|
|
|
|
|
|
// Compute Diyanet prayer times, returning UTC-aware datetimes.
|
|
|
//
|
|
|
// Solar noon (Dhuhr) is the central reference. All other times are offsets:
|
|
|
//
|
|
|
// Morning times (Imsak, Sunrise): noon − H + temkin
|
|
|
// Afternoon/evening times (Asr, Maghrib, Isha): noon + H + temkin
|
|
|
//
|
|
|
// Internally computes solar noon at UTC (tz=0), so results are in UTC.
|
|
|
//
|
|
|
// Solar noon formula: T_noon = 12 + TZ − lambda/15 − EoT/60
|
|
|
//
|
|
|
// lambda/15 converts longitude to hours (15deg/h)
|
|
|
// EoT corrects the gap between mean solar time and apparent solar time
|
|
|
func prayerTimes(lat, lon float64, d time.Time) computedTimes {
|
|
|
jd := julianDay(d)
|
|
|
delta, eot := sunParams(jd)
|
|
|
|
|
|
// UTC solar noon: tz=0, so T_noon = 12 − lambda/15 − EoT/60
|
|
|
noonUTC := 12 - lon/degPerHour - eot/60.0
|
|
|
|
|
|
compute := func(base, angle float64, sign int, temkinMinutes float64) *time.Time {
|
|
|
h, ok := hourAngle(angle, lat, delta)
|
|
|
if !ok {
|
|
|
return nil
|
|
|
}
|
|
|
t := decimalHoursToUTC(base+float64(sign)*h+temkinMinutes/60.0, d)
|
|
|
return &t
|
|
|
}
|
|
|
|
|
|
hAsr, hasAsr := hourAngle(asrAltitude(lat, delta), lat, delta)
|
|
|
var asr *time.Time
|
|
|
if hasAsr {
|
|
|
t := decimalHoursToUTC(noonUTC+hAsr+temkin.Asr/60.0, d)
|
|
|
asr = &t
|
|
|
}
|
|
|
|
|
|
// Sunset for output is the geometric sunset without temkin offset.
|
|
|
geometricSunset := func(base, angle float64) *time.Time {
|
|
|
h, ok := hourAngle(angle, lat, delta)
|
|
|
if !ok {
|
|
|
return nil
|
|
|
}
|
|
|
t := decimalHoursToUTC(base+h, d)
|
|
|
return &t
|
|
|
}
|
|
|
|
|
|
tDhuhr := decimalHoursToUTC(noonUTC+temkin.Dhuhr/60.0, d)
|
|
|
|
|
|
return computedTimes{
|
|
|
Imsak: compute(noonUTC, imsakAngle, -1, temkin.Imsak),
|
|
|
Sunrise: compute(noonUTC, sunAngle, -1, temkin.Sunrise),
|
|
|
Dhuhr: &tDhuhr,
|
|
|
Asr: asr,
|
|
|
Sunset: geometricSunset(noonUTC, sunAngle),
|
|
|
Maghrib: compute(noonUTC, sunAngle, +1, temkin.Maghrib),
|
|
|
Isha: compute(noonUTC, ishaAngle, +1, temkin.Isha),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Convert UTC prayer time datetimes to HH:MM strings at a given UTC offset.
|
|
|
//
|
|
|
// tzOffset is UTC offset in hours, e.g. 3 for Turkey (UTC+3).
|
|
|
func formatHHMM(dt *time.Time, tzOffset float64) string {
|
|
|
if dt == nil {
|
|
|
return ""
|
|
|
}
|
|
|
|
|
|
tz := time.FixedZone("estimated", int(tzOffset*3600))
|
|
|
return dt.In(tz).Add(30 * time.Second).Format("15:04")
|
|
|
}
|
|
|
|
|
|
func estimateUTCOffsetHours(longitude float64) float64 {
|
|
|
offset := math.Round(longitude / degPerHour)
|
|
|
if offset < -12 {
|
|
|
return -12
|
|
|
}
|
|
|
if offset > 14 {
|
|
|
return 14
|
|
|
}
|
|
|
return offset
|
|
|
}
|
|
|
|
|
|
func toRadians(deg float64) float64 {
|
|
|
return deg * math.Pi / 180.0
|
|
|
}
|
|
|
|
|
|
func toDegrees(rad float64) float64 {
|
|
|
return rad * 180.0 / math.Pi
|
|
|
}
|