Post

ENG | Calculating sunrise and sunset

How to calculate sunrise and sunset (it's complicated)

ENG | Calculating sunrise and sunset

I asked AI how to compute sunrise and sunset and to generate code in Julia. I don’t use this language beyond better calculator, but it’s probably the most readable and actually suitable for this stuff. It generated functional code which had errors in order of minutes. Then I asked it why it’s inaccurate, it generated better code with error of minute or two. It was not that long - like 400 lines with lot of comments, nice output formatting, it included twilight times.

But then what to even use as a reference? timeanddate.com, Garmin smart watch? Why do they have one minute differences?

Known facts

  • Earth travels on ellipsis with slight variation in distance from sun and angular speed - roughly 1° per day
  • Earth rotates around is axis which has (almost) fixed orientation towards stars
  • Earth axis is tilted by 23.44° relative to its orbital plane
  • Earth rotation relative to stars (sideric day has 23h56m4.09s) and sun (24h) differs
  • Earth rotates around sun on ellipsis going through nearest point every xxx days

Weird facts

  • Sideric year: 365.256363 days (full rotation around sun) - but one extra rotation towards stars
  • Tropical year: 365.242190 days (from spring equinox to spring equinox)
  • Somehow difference is ~20 minutes yearly, 1 day in ~70 years and one year in ~26000 years -> polaris won’t be northern star forever, in 13000 years Orion can be summer constelation and earth will be closest to sun in July.
  • Tilt of axis changes a bit

I assumed that sunrise/sunset could not be that hard cause earth moves on ellipsis so we need to solve position relative to sun, then it rotates around axis with period relative stars and we can simply solve position and at given time compute azimuth and elevation of sun at given location. Well, for some reason it’s not that simple.

Position on ellipsis can’t be directly solved so it seems to be approximated by 2nd or 3rd order polynom fit.

So where errors possibly come from?

  • Rounding seconds to minutes
  • Polynom fits
  • Ignoring leap years
  • Sun crossing horizon (center/top, athmopheric refraction correction - which, sadly, depend on current weather)

In the end I dropped AI generated Julia code almost entirely and use Excel sheet from NOAA as a starting point. Strength of Julia code was it’s structure.

Time conversion

We have various time references. Curiously, Excel has 1900-01-01 as day 1 for backward compatibility with Lotus 1-2-3 (some may remember Lotus AmiPro or Lotus Organizer from 90s). Except Lotus 1-2-3 had a bug, that considered 1900 as a leap year and Excel keeps this. That’s why day 0 in Excel is 1899-12-30.

Astronomers use an even weirder system: the Julian Day Number (JD), which counts days from January 1, 4713 BCE at noon—a date chosen because it’s when three ancient calendar cycles (28-year solar, 19-year lunar, 15-year Roman tax) all aligned at their starting point. It predates all recorded history, making it perfect for astronomical calculations without worrying about calendar reforms. And noon, cause whole night has the same date.

  • 2400000.5 1858-11-17 00:00 Modified Julian day (shorter)
  • 2415018.5 1899-12-30 00:00 (1-2-3, Excel epoch)
  • 2451545.0 2000-01-01 12:00 (J2000.0 epoch)

Not to mention leap seconds and various different continous atomic times.

Code snippets

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def excel_date_to_julian_day(excel_date, timezone_offset):
    # Excel's date system starts at 1900-01-01 (Excel day 1)
    # But Excel incorrectly assumes 1900 is a leap year, so day 60 is 1900-02-29 (which doesn't exist)
    # So we adjust for this bug (comes from Lotus-1-2-3)
    excel_epoch = datetime(1899, 12, 30)
    delta = timedelta(days=excel_date)  # Subtract 2 to correct for Excel's 1900 leap year bug
    date = excel_epoch + delta

    # Calculate Julian Day
    # Excel formula: JD = DATE + 2415018.5 + DAY_FRACTION - TIMEZONE/24
    # Here, DAY_FRACTION is 0.5 for noon, but you can adjust as needed
    jd = excel_date + 2415018.5 - timezone_offset / 24
    return jd

def date_to_excel_serial_date(date):
    # Excel epoch (1900-01-01) with the 1900 leap year bug correction
    excel_epoch = datetime(1899, 12, 30)
    delta = date - excel_epoch
    return delta.days

def date_to_julian_day(date, timezone_offset):
    # https://en.wikipedia.org/wiki/Julian_day#History yes, we have year 6738 in 2025
    excel_date = date_to_excel_serial_date(date)
    jd = excel_date_to_julian_day(excel_date, timezone_offset)    
    return jd

FIXME: date, make it function

1
2
# Výpočet Julian Century (G)
julian_century      = (julian_day(datetime(2025, 12, 24), timezone_offset) + time_past_midnight - 2451545)/36525    

Because nothing is nice and constant, there are some weird approximation by polynoms of 2nd or 3rd degree. In the end we need sun_declination (angle from equator) and equation_of_time which is offset of solar noon (sundial time vs mechanical clock).

These depend only on time. Note that this equation gives different result for morning, noon, evening and midnight and it also ignores timezones. So it’s valid for every place on earth at any given moment.

Curiously equation of time can differ by tens seconds per day (winter days are roughly 30s longer than average)

Getting equation of time and declination

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def precalc(julian_century):
    # Geometrická střední délka slunce (I)
    geom_mean_long_sun = (280.46646 + julian_century * (36000.76983 + julian_century * 0.0003032)) % 360

    # Geometrická střední anomálie slunce (J)
    geom_mean_anom_sun = 357.52911 + julian_century * (35999.05029 - 0.0001537 * julian_century)

    # Excentricita zemské dráhy (K)
    eccent_earth_orbit = 0.016708634 - julian_century * (0.000042037 + 0.0000001267 * julian_century)

    # Rovnice středu (L)
    sun_eq_of_ctr = (
          math.sin(math.radians(    geom_mean_anom_sun)) * (1.914602 - julian_century * (0.004817 + 0.000014 * julian_century))
        + math.sin(math.radians(2 * geom_mean_anom_sun)) * (0.019993 - 0.000101 * julian_century)
        + math.sin(math.radians(3 * geom_mean_anom_sun)) * 0.000289
    )

    # Pravá délka slunce (M)
    sun_true_long = geom_mean_long_sun + sun_eq_of_ctr

    # Pravá anomálie slunce (N)
    sun_true_anom = geom_mean_anom_sun + sun_eq_of_ctr

    # Vzdálenost slunce od Země (O)
    sun_rad_vector = (1.000001018 * (1 - eccent_earth_orbit**2)) / (1 + eccent_earth_orbit * math.cos(math.radians(sun_true_anom)))

    # Zdánlivá délka slunce (P)
    sun_app_long = sun_true_long - 0.00569 - 0.00478 * math.sin(math.radians(125.04 - 1934.136 * julian_century))

    # Střední sklon ekliptiky (Q)
    mean_obliq_ecliptic = 23 + (26 + ((21.448 - julian_century * (46.815 + julian_century * (0.00059 - julian_century * 0.001813)))) / 60) / 60

    # Korekce sklonu ekliptiky (R)
    obliq_corr = mean_obliq_ecliptic + 0.00256 * math.cos(math.radians(125.04 - 1934.136 * julian_century))

    # Rektascenze slunce (S)
    # https://en.wikipedia.org/wiki/Right_ascension
    sun_rt_ascen = math.degrees(
        math.atan2(
            math.cos(math.radians(sun_app_long)),
            math.cos(math.radians(obliq_corr)) * math.sin(math.radians(sun_app_long)),
        )
    )

    # Deklinace slunce (T)
    sun_declin = math.degrees(math.asin(math.sin(math.radians(obliq_corr)) * math.sin(math.radians(sun_app_long))))

    # Pomocná proměnná (U)
    var_y = math.tan(math.radians(obliq_corr / 2)) ** 2

    # Rovnice času (V)
    eq_of_time = 4 * math.degrees(
        var_y * math.sin(2 * math.radians(geom_mean_long_sun))
        - 2 * eccent_earth_orbit * math.sin(math.radians(geom_mean_anom_sun))
        + 4 * eccent_earth_orbit * var_y * math.sin(math.radians(geom_mean_anom_sun)) * math.cos(2 * math.radians(geom_mean_long_sun))
        - 0.5 * var_y**2 * math.sin(4 * math.radians(geom_mean_long_sun))
        - 1.25 * eccent_earth_orbit**2 * math.sin(2 * math.radians(geom_mean_anom_sun))
    )

    return (sun_declin, eq_of_time)

We can precompute sun declination and equation of time, because this part is the most difficult calculation.

Solar position at time & Time of solar elevation

Then there are relatively simple functions that depend on latitude, longitude and can give answer to how early before noon sun reaches certain elevation over/below horizon (to calculate twilight, sunrise, golden hour) and second function is for solar position.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def hour_angle_for_elevation(latitude, declination, elevation):
    lat_rad  = math.radians(latitude)
    dec_rad  = math.radians(declination)
    elev_rad = math.radians(elevation)
    
    # Spherical astronomy formula
    cos_h = (math.sin(elev_rad) - math.sin(lat_rad) * math.sin(dec_rad)) / (math.cos(lat_rad) * math.cos(dec_rad))
    
    # Check if sun reaches this elevation today
    if cos_h < -1.0:
        return None  # Sun always above this elevation (polar summer)
    elif cos_h > 1.0:
        return None  # Sun never reaches this elevation (polar winter)

    return math.degrees(math.acos(cos_h))


def solar_position(latitude, sun_declin, eq_of_time, local_hour):
    """
    Calculate solar azimuth and elevation at given time

    Parameters:
    - latitude: observer latitude (degrees)
    - doy: day of year
    - solar_hour: time in solar hours (0-24)

    Returns:
    - (azimuth, elevation) in degrees
    - azimuth: 0° = North, 90° = East, 180° = South, 270° = West
    - elevation: angle above horizon (negative = below horizon)
    """

    # Hour angle from solar noon (15° per hour)
    solar_hour = local_hour + longitude/15.0 - timezone_offset
    hour_angle = 15.0 * (solar_hour - 12.0 + eq_of_time/60.0)
    
    lat_rad = math.radians(latitude)
    dec_rad = math.radians(sun_declin)
    h_rad = math.radians(hour_angle)
    
    # Solar elevation (altitude)
    sin_elev = math.sin(lat_rad) * math.sin(dec_rad) + \
               math.cos(lat_rad) * math.cos(dec_rad) * math.cos(h_rad)
    elevation = math.degrees(math.asin(sin_elev))
    
    # Solar azimuth
    cos_azim = (math.sin(dec_rad) - math.sin(lat_rad) * sin_elev) / \
               (math.cos(lat_rad) * math.cos(math.asin(sin_elev)))
    # Clamp to prevent numerical errors
    #cos_azim = math.clamp(cos_azim, -1.0, 1.0)
    if cos_azim > 1:
        cos_azim = 1
    if cos_azim < -1:
        cos_azim = -1
    azimuth = math.degrees(math.acos(cos_azim))
    
    # Adjust azimuth for afternoon (hour angle > 0)
    if hour_angle > 0:
        azimuth = 360.0 - azimuth

    return (azimuth, elevation)

Test code

Test code may look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# Brno, CZ, Náměstí svobody
latitude  = 49.19528 
longitude = 16.60778

timezone_offset = 1   # UTC+1, CET

time_past_midnight = 0.5

# Precompute array of sun_declin, eq_of_time
start_date = datetime(2025, 12, 1)
end_date   = datetime(2025, 12, 31)

current_date = start_date
julian_century = date_to_julian_century(start_date, timezone_offset, time_past_midnight)
array = []
while current_date <= end_date:
    sun_declin, eq_of_time = precalc(julian_century)
    array.append((sun_declin, eq_of_time))
    current_date   += timedelta(days=1)
    julian_century += 1/DAYS_PER_CENTURY


for index, (sun_declin, eq_of_time) in enumerate(array):
    sun_declin, eq_of_time = array[index]
    current_date = start_date + timedelta(days=index)
    ha_sunrise = hour_angle_for_elevation(latitude, sun_declin, -0.8333)

    # Sluneční poledne (X)
    solar_noon = (720 - 4 * longitude - eq_of_time + timezone_offset * 60) / 1440

    # Čas východu slunce (Y)
    sunrise_time = solar_noon - ha_sunrise * 4 / 1440

    # Čas západu slunce (Z)
    sunset_time = solar_noon + ha_sunrise * 4 / 1440
    print(f"{current_date:%Y-%m-%d}\t{fraction_to_time(sunrise_time)}\t{fraction_to_time(solar_noon)}\t{fraction_to_time(sunset_time)}")


date_to_julian_century(datetime(2025, 12, 24), timezone_offset, time_past_midnight)
for hour in range (0, 23):
    sun_declin, eq_of_time = precalc(julian_century)
    azimuth, elevation = solar_position(latitude, sun_declin, eq_of_time, hour)
    print(f"{hour:02d}\t{azimuth}\t{elevation}")

Example output

Sunrise, noon, and sunset times in December 2025, Brno, Czech Republic

DateSunriseNoonSunset
2025-12-0107:27:2111:42:4015:57:59
2025-12-0207:28:3711:43:0315:57:28
2025-12-0307:29:5211:43:2715:57:01
2025-12-0407:31:0511:43:5115:56:37
2025-12-0507:32:1511:44:1615:56:16
2025-12-0607:33:2411:44:4115:55:57
2025-12-0707:34:3111:45:0715:55:42
2025-12-0807:35:3611:45:3315:55:30
2025-12-0907:36:3811:46:0015:55:21
2025-12-1007:37:3911:46:2715:55:15
2025-12-1107:38:3711:46:5415:55:12
2025-12-1207:39:3211:47:2215:55:12
2025-12-1307:40:2511:47:5015:55:16
2025-12-1407:41:1611:48:1915:55:22
2025-12-1507:42:0411:48:4815:55:31
2025-12-1607:42:5011:49:1715:55:44
2025-12-1707:43:3311:49:4615:56:00
2025-12-1807:44:1311:50:1515:56:18
2025-12-1907:44:5011:50:4515:56:40
2025-12-2007:45:2411:51:1515:57:05
2025-12-2107:45:5611:51:4415:57:33
2025-12-2207:46:2511:52:1415:58:03
2025-12-2307:46:5111:52:4415:58:37
2025-12-2407:47:1411:53:1415:59:14
2025-12-2507:47:3411:53:4315:59:53
2025-12-2607:47:5111:54:1316:00:35
2025-12-2707:48:0411:54:4216:01:20
2025-12-2807:48:1511:55:1216:02:08
2025-12-2907:48:2311:55:4116:02:58
2025-12-3007:48:2811:56:1016:03:51
2025-12-3107:48:3011:56:3816:04:47

Sun azimuth and elevation on 2025-12-24

HourAzimuthElevation
01.50-63.78
130.97-61.00
253.82-54.36
370.66-45.69
483.91-36.14
595.33-26.35
6106.00-16.73
7116.61-7.61
8127.670.68
9139.567.77
10152.4513.25
11166.2916.71
12180.7017.82
13195.0816.48
14208.8412.81
15221.637.15
16233.42-0.07
17244.42-8.46
18255.02-17.64
19265.72-27.29
20277.26-37.07
21290.77-46.57
22308.07-55.11

MicroPython code using precomputed table

Code in MicroPython, may look like this and possibly run on ESP32 or Raspberry Pi Pico.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/micropython

import struct, math, time

declination = []
equation_of_time = []

def date_to_index(year, month, day, ref_year=2025, ref_month=12, ref_day=1):
    """Convert a date to table index (days since reference date)"""
    date_ts = time.mktime((year, month, day, 0, 0, 0, 0, 0))
    ref_ts = time.mktime((ref_year, ref_month, ref_day, 0, 0, 0, 0, 0))
    return (date_ts - ref_ts) // 86400

def index_to_date(index, ref_year=2025, ref_month=12, ref_day=1):
    """Convert table index to (year, month, day) tuple"""
    ref_ts = time.mktime((ref_year, ref_month, ref_day, 0, 0, 0, 0, 0, 0))
    target_ts = ref_ts + (index * 86400)
    date_tuple = time.localtime(target_ts)
    return (date_tuple[0], date_tuple[1], date_tuple[2])

def index_to_date_str(index, ref_year=2025, ref_month=12, ref_day=1):
    """Convert table index to date string YYYY-MM-DD"""
    year, month, day = index_to_date(index, ref_year, ref_month, ref_day)
    return f"{year}-{month:02d}-{day:02d}"

def fraction_to_time(fraction):
    hours   = int(fraction * 24)
    minutes = int((fraction * 24 - hours) * 60)
    seconds = int((fraction * 86400) % 60)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}"

def hour_angle_for_elevation(latitude, declination, elevation):
    lat_rad  = math.radians(latitude)
    dec_rad  = math.radians(declination)
    elev_rad = math.radians(elevation)
    
    # Spherical astronomy formula
    cos_h = (math.sin(elev_rad) - math.sin(lat_rad) * math.sin(dec_rad)) / (math.cos(lat_rad) * math.cos(dec_rad))
    
    # Check if sun reaches this elevation today
    if cos_h < -1.0:
        return None  # Sun always above this elevation (polar summer)
    elif cos_h > 1.0:
        return None  # Sun never reaches this elevation (polar winter)

    return math.degrees(math.acos(cos_h))

## Main program #############################################3

with open('sun_data.bin', 'rb') as f:
    count = struct.unpack('H', f.read(2))[0]
    for _ in range(count):
        decl, eot = struct.unpack('ff', f.read(8))
        declination.append(decl)
        equation_of_time.append(eot)

latitude  = 49.19528 
longitude = 16.60778
timezone_offset = 1   # UTC+1, CET

for i in range(0, 35):
    sun_declin = declination[i]
    eq_of_time = equation_of_time[i]
    ha_sunrise = hour_angle_for_elevation(latitude, sun_declin, -0.8333)
    solar_noon = (720 - 4 * longitude - eq_of_time + timezone_offset * 60) / 1440
    sunrise_time = solar_noon - ha_sunrise * 4 / 1440
    sunset_time  = solar_noon + ha_sunrise * 4 / 1440
    print(f"| {index_to_date_str(i)} | {fraction_to_time(sunrise_time)} | {fraction_to_time(solar_noon)} | {fraction_to_time(sunset_time)} |")

Quick Summary

  • Sunrise/sunset calculation is surprisingly complex due to Earth’s elliptical orbit and axial tilt
  • Accuracy within 1 minute requires polynomial approximations and careful time conversions
  • Can pre-compute declination/equation-of-time tables for efficient embedded use
  • Link to code snippets on GitHub
This post is licensed under CC BY 4.0 by the author.