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
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
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
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
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
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
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())
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
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
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));
}
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);
}
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
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)