You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

323 lines
11 KiB
Go

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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 1020 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
}