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 }