DEV Community

KunStudio
KunStudio

Posted on

True Solar Time in Code: Your Clock Is Not the Sun

If your code needs to know where the sun actually was at a given moment, the timestamp on the clock is not the number you want. Solar yield models, sundial software, golden-hour tools, agronomy models and traditional calendar systems all run into this. Civil time is an administrative decision. Solar time is astronomy. Two independent corrections sit between them.

Neither correction is hard. What is easy to get wrong is the input you feed them, and I will get to that at the end, because it is the part that actually broke things for me.

Correction 1: you are not standing on the zone meridian

A time zone is a band of longitude that agrees to share one clock. That clock is anchored to a meridian: UTC+9 is anchored to 135 degrees east. If you are not standing on 135E, your local noon is not the zone's noon.

The Earth rotates 360 degrees in 1440 minutes, so one degree of longitude is four minutes of time.

MINUTES_PER_DEGREE = 4.0  # Earth turns 360 deg in 1440 min

def longitude_correction_minutes(longitude_deg, utc_offset_hours):
    """Minutes to add to clock time to reach local mean solar time."""
    zone_meridian = utc_offset_hours * 15.0
    return (longitude_deg - zone_meridian) * MINUTES_PER_DEGREE
Enter fullscreen mode Exit fullscreen mode

Korea is the textbook case. It keeps UTC+9, anchored to 135E, but Seoul sits at roughly 126.978E. That is eight degrees of slack:

Seoul     lon  126.9780  ->  -32.09 min
Tokyo     lon  139.6917  ->  +18.77 min
London    lon   -0.1276  ->   -0.51 min
New York  lon  -74.0060  ->   +3.98 min
Enter fullscreen mode Exit fullscreen mode

Seoul's sun runs about 32 minutes behind Seoul's clocks, permanently. London, sitting almost on the prime meridian, needs half a minute. This offset is fixed for a given location and zone, and it is not seasonal.

Correction 2: the equation of time

The second correction is seasonal, and it is the more interesting one. Even a perfect sundial standing on the zone meridian disagrees with a perfect clock, because the Earth's orbit is elliptical and its axis is tilted. Apparent solar days are not all the same length. The accumulated difference is the equation of time, and it swings across roughly half an hour over the year.

This is the NOAA Solar Calculator formulation. It is a truncated series rather than a full ephemeris, which makes it small enough to inline anywhere:

import math

def julian_century(dt_utc):
    y, m = dt_utc.year, dt_utc.month
    if m <= 2:
        y -= 1
        m += 12
    a = y // 100
    b = 2 - a + a // 4
    day_fraction = (dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600) / 24
    jd = (math.floor(365.25 * (y + 4716))
          + math.floor(30.6001 * (m + 1))
          + dt_utc.day + day_fraction + b - 1524.5)
    return (jd - 2451545.0) / 36525.0

def equation_of_time_minutes(dt_utc):
    t = julian_century(dt_utc)
    l0 = math.radians((280.46646 + t * (36000.76983 + t * 0.0003032)) % 360.0)
    m = math.radians(357.52911 + t * (35999.05029 - 0.0001537 * t))
    e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t)
    seconds = 21.448 - t * (46.8150 + t * (0.00059 - t * 0.001813))
    eps0 = 23.0 + (26.0 + seconds / 60.0) / 60.0
    omega = math.radians(125.04 - 1934.136 * t)
    eps = math.radians(eps0 + 0.00256 * math.cos(omega))
    y = math.tan(eps / 2.0) ** 2
    eot = (y * math.sin(2 * l0)
           - 2 * e * math.sin(m)
           + 4 * e * y * math.sin(m) * math.cos(2 * l0)
           - 0.5 * y * y * math.sin(4 * l0)
           - 1.25 * e * e * math.sin(2 * m))
    return math.degrees(eot) * 4.0
Enter fullscreen mode Exit fullscreen mode

Validate it before you trust it. Sweep a year and look for the extremes, which are well documented:

max EoT 2026-11-03 16.49
min EoT 2026-02-11 -14.23
Enter fullscreen mode Exit fullscreen mode

Published tables put the annual minimum on Feb 11 near -14.24 minutes and the annual maximum in early November near +16.49 minutes. Matching to two decimal places is a good sign the series is transcribed correctly. If you port this and your February minimum comes out positive, you have a sign error in the eccentricity term.

Putting them together

from datetime import timedelta, timezone

def true_solar_time(local_dt, longitude_deg):
    """local_dt must be timezone-aware."""
    offset_hours = local_dt.utcoffset().total_seconds() / 3600.0
    lon_corr = longitude_correction_minutes(longitude_deg, offset_hours)
    eot = equation_of_time_minutes(local_dt.astimezone(timezone.utc))
    total = lon_corr + eot
    return local_dt.replace(tzinfo=None) + timedelta(minutes=total), total
Enter fullscreen mode Exit fullscreen mode

For a clock reading of 23:30 in Seoul, across a year:

clock (KST)    lon(min)   eot(min)      total   true solar time
2026-01-15       -32.09      -9.44     -41.53   2026-01-15 22:48:28
2026-04-15       -32.09       0.00     -32.09   2026-04-15 22:57:54
2026-07-15       -32.09      -6.03     -38.11   2026-07-15 22:51:53
2026-10-15       -32.09      14.27     -17.82   2026-10-15 23:12:10
2026-11-15       -32.09      15.43     -16.66   2026-11-15 23:13:20
Enter fullscreen mode Exit fullscreen mode

The total correction moves between about -42 and -17 minutes depending on the date. A single hardcoded constant will be wrong by up to 25 minutes.

The part that actually bites: the offset is an input, not a constant

Look at true_solar_time again. It reads utcoffset() off the datetime you hand it. Everything downstream depends on that being right, and this is where real code fails, because UTC offsets are historical facts rather than properties of a country.

Korea changed its standard time more than once. It adopted UTC+08:30 in 1908, moved to UTC+09:00 in 1912, went back to UTC+08:30 in 1954, and returned to UTC+09:00 in 1961. It also ran summer time in several years. The IANA time zone database knows all of this:

from zoneinfo import ZoneInfo
from datetime import datetime

tz = ZoneInfo("Asia/Seoul")
for y in (1954, 1955, 1960, 1961, 1962, 1988, 1989):
    print(y, datetime(y, 7, 1, 12, tzinfo=tz).utcoffset())
Enter fullscreen mode Exit fullscreen mode
1954 8:30:00
1955 9:30:00
1960 9:30:00
1961 8:30:00
1962 9:00:00
1988 10:00:00
1989 9:00:00
Enter fullscreen mode Exit fullscreen mode

July 1988 is UTC+10, not UTC+9, because South Korea observed daylight saving around the Seoul Olympics. Hardcode timezone(timedelta(hours=9)) and every summer 1987 and 1988 local time in your database is an hour off before the solar correction even runs.

Here is what that costs. Traditional East Asian calendars bucket the day into twelve two-hour branches, so a shifted timestamp can land in a different bucket:

hardcoded UTC+9      offset=9:00:00   lon=-32.09  eot=-5.95  total=-38.04  -> 21:21:57  branch=Pig
Asia/Seoul (tzdata)  offset=10:00:00  lon=-92.09  eot=-5.95  total=-98.04  -> 20:21:57  branch=Dog
Enter fullscreen mode Exit fullscreen mode

Same record, different answer. Note that the longitude correction itself also changed, from -32 to -92 minutes, because the zone meridian moved with the offset. The bug compounds rather than cancelling out.

I ran into this while building Cheonmyeongdang, a Korean Saju (Four Pillars) calculator that derives a chart from a birth date and hour. When the hour pillar is a two-hour bucket and your correction is half an hour wide, boundary cases are not rare, they are routine.

The rule is short: never construct a fixed-offset timezone for a historical local time. Use zoneinfo in Python, or the equivalent tz-aware type in your language, and let the database tell you the offset.

JavaScript

Same maths, and the offset lookup goes through Intl, so it respects historical transitions too:

function zoneOffsetMinutes(date, timeZone) {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone: timeZone,
    timeZoneName: 'longOffset',
  }).formatToParts(date);
  const name = parts.find((p) => p.type === 'timeZoneName').value; // "GMT+10:00"
  const m = name.match(/GMT([+-])(\d{2}):?(\d{2})?/);
  if (!m) return 0;
  return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] || 0));
}
Enter fullscreen mode Exit fullscreen mode

Converting wall-clock fields to an instant needs one fixed-point step, since you cannot know the offset until you know the instant:

function wallClockToInstant(w, timeZone) {
  const naive = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
  let ts = naive;
  for (let i = 0; i < 2; i++) {
    ts = naive - zoneOffsetMinutes(new Date(ts), timeZone) * 60000;
  }
  return new Date(ts);
}
Enter fullscreen mode Exit fullscreen mode

Running the 1988 case through the JavaScript port on Node 24:

Asia/Seoul  offset=UTC+10  lon=-92.09  eot=-5.95  total=-98.04  -> 20:21:57
Enter fullscreen mode Exit fullscreen mode

Identical to the Python, which is the check you want whenever you maintain two implementations of the same formula.

Caveats

The NOAA series is a low-precision approximation. It is fine for minute-level work and not intended for arcsecond astronomy, so reach for a real ephemeris if you need better.

"Longitude of the city" is also a simplification. A city is not a point, and Seoul spans enough longitude to move the correction by a fraction of a minute. Pick a documented reference point and write it down rather than letting it drift between modules.

Finally, decide explicitly whether your domain wants apparent solar time, which is both corrections, or local mean solar time, which is longitude only. Different traditions and technical standards answer that differently. Silently mixing the two across a codebase is worse than picking the less popular one and applying it consistently.

Top comments (0)