From f520473ed3e82d039e375c1bddb6e64fde8a4545 Mon Sep 17 00:00:00 2001 From: Abdussamet Kocak Date: Sat, 21 Feb 2026 00:05:43 +0300 Subject: [PATCH] feat: Add calculated prayer times provider --- main.go | 6 +- pkg/diyanetcalc/provider.go | 322 ++++++++++++++++++++++++++++++++ pkg/diyanetprovider/provider.go | 81 ++++++++ 3 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 pkg/diyanetcalc/provider.go create mode 100644 pkg/diyanetprovider/provider.go diff --git a/main.go b/main.go index 2b897d4..986a9ea 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,8 @@ import ( "prayertimes/internal/api" "prayertimes/internal/net" "prayertimes/pkg/diyanetapi" + "prayertimes/pkg/diyanetcalc" + "prayertimes/pkg/diyanetprovider" ) func main() { @@ -27,9 +29,11 @@ func main() { func newServices() (api.Services, error) { diyanetAPIProvider := diyanetapi.New(net.ReqClient) + diyanetCalcProvider := diyanetcalc.New() + provider := diyanetprovider.New(diyanetAPIProvider, diyanetCalcProvider) return api.Services{ - Provider: diyanetAPIProvider, + Provider: provider, }, nil } diff --git a/pkg/diyanetcalc/provider.go b/pkg/diyanetcalc/provider.go new file mode 100644 index 0000000..a01b8d0 --- /dev/null +++ b/pkg/diyanetcalc/provider.go @@ -0,0 +1,322 @@ +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 +} diff --git a/pkg/diyanetprovider/provider.go b/pkg/diyanetprovider/provider.go new file mode 100644 index 0000000..731433d --- /dev/null +++ b/pkg/diyanetprovider/provider.go @@ -0,0 +1,81 @@ +package diyanetprovider + +import ( + "context" + "errors" + "fmt" + "time" + + "prayertimes/pkg/prayer" +) + +type APIProvider interface { + Get(ctx context.Context, locationID string) ([]prayer.Times, error) + GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) + SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) +} + +type FallbackProvider interface { + GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) +} + +type Provider struct { + api APIProvider + fallback FallbackProvider + timeout time.Duration +} + +func New(api APIProvider, fallback FallbackProvider) Provider { + return Provider{ + api: api, + fallback: fallback, + timeout: time.Second, + } +} + +func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) { + locations, err := p.api.SearchLocations(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to search locations from api provider: %w", err) + } + + return locations, nil +} + +func (p Provider) Get(ctx context.Context, locationID string) ([]prayer.Times, error) { + ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout) + defer cancel() + + times, err := p.api.Get(ctxWithTimeout, locationID) + if err != nil { + return nil, fmt.Errorf("failed to get prayer times from api provider: %w", err) + } + if len(times) == 0 { + return nil, fmt.Errorf("failed to get prayer times from api provider: %w", errors.New("empty prayer times result")) + } + + return times, nil +} + +func (p Provider) GetByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Times, error) { + ctxWithTimeout, cancel := context.WithTimeout(ctx, p.timeout) + defer cancel() + + times, err := p.api.GetByCoords(ctxWithTimeout, coords) + if err == nil && len(times) > 0 { + return times, nil + } + + fallbackTimes, fallbackErr := p.fallback.GetByCoords(ctx, coords) + if fallbackErr != nil { + if err != nil { + return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", errors.Join(err, fallbackErr)) + } + return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", fallbackErr) + } + if len(fallbackTimes) == 0 { + return nil, fmt.Errorf("failed to get prayer times from fallback provider: %w", errors.New("empty prayer times result")) + } + + return fallbackTimes, nil +}