Why Are the Timestamps in My Map Animation Wrong?
On this page
Problem statement
The frames are right but the clock on them is not. The label says 01:30 when the satellite passed at 06:30 UTC. Or the evening storm appears in the morning. Or, on the night clocks change, two different frames carry the same time and the animation seems to run backwards. Almost all source data uses UTC, while readers think in local time. The conversion goes wrong in three ways: no conversion, a fixed offset, or local times used as keys. Daylight saving makes each of them visible twice a year.
This page uses GOES-16 scans over Texas on the two nights US clocks changed in 2024 to reproduce each mistake and fix it.
Quick answer
Keep UTC as the frame key, convert to a named time zone only for the label, and print the zone abbreviation:
local = FRAMES.tz_convert("America/Chicago")
labels = [f"{t:%d %b %H:%M} {t:%Z}" for t in local]
print(f"{len(FRAMES)} frames, {FRAMES[0]:%H:%M}β{FRAMES[-1]:%H:%M} UTC")
print(" | ".join(labels[3:9]))
16 frames, 05:01β08:46 UTC
03 Nov 00:46 CDT | 03 Nov 01:01 CDT | 03 Nov 01:16 CDT | 03 Nov 01:31 CDT | 03 Nov 01:46 CDT | 03 Nov 01:01 CST
UTC from the start, no fixed offsets, no local keys
1. Read times as UTC from the start
GOES file names carry the scan start in UTC as year, day of year, hour and minute. The setup parses them with utc=True, so pandas knows which clock they are on:
name = keys[0].split("/")[-1]
print(name)
print("scan start:", pd.to_datetime(re.search(r"_s(\d{11})", name).group(1), format="%Y%j%H%M", utc=True))
print(f"{len(keys)} scans, kept {len(FRAMES)} (first in each 15 minutes), tz = {FRAMES.tz}")
OR_ABI-L2-CMIPC-M6C13_G16_s20243080501172_e20243080503558_c20243080504065.nc
scan start: 2024-11-03 05:01:00+00:00
48 scans, kept 16 (first in each 15 minutes), tz = UTC
2. Do not use a fixed offset
Central Daylight Time is UTCβ5 and Central Standard Time is UTCβ6. A fixed offset is right on one side of the change and wrong by an hour on the other:
fixed = FRAMES + pd.Timedelta(hours=-5) # "CDT" hard-coded
named = FRAMES.tz_convert("America/Chicago")
wrong = (fixed.tz_localize(None) != named.tz_localize(None)).sum()
print(f"fixed UTC-5 labels wrong on {wrong} of {len(FRAMES)} frames, from {FRAMES[(fixed.tz_localize(None) != named.tz_localize(None))][0]:%H:%M} UTC")
fixed UTC-5 labels wrong on 8 of 16 frames, from 07:01 UTC
3. Do not use local clock times as keys
Between 01:00 and 02:00 local time on the night clocks go back, every local time happens twice. If frames are stored or sorted by naive local time, the two hours collide:
naive_local = named.tz_localize(None)
dupes = naive_local[naive_local.duplicated(keep=False)]
print(f"{len(dupes)} frames share a local clock time with another frame: {sorted({t.strftime('%H:%M') for t in dupes})}")
try:
pd.DatetimeIndex(naive_local).tz_localize("America/Chicago")
except Exception as e:
print(type(e).__name__, "-", str(e)[:70])
8 frames share a local clock time with another frame: ['01:01', '01:16', '01:31', '01:46']
ValueError - Cannot infer dst time from 2024-11-03 01:01:00, try using the 'ambiguo
4. Check the spring change too
When clocks go forward, an hour of local time never happens. A schedule written in local time asks for frames that cannot exist:
spring = pd.date_range("2024-03-10 01:00", "2024-03-10 03:45", freq="15min") # a local "every 15 minutes" plan
missing = [t for t in spring if pd.Timestamp(t).tz_localize("America/Chicago", nonexistent="NaT") is pd.NaT]
print(f"{len(missing)} of {len(spring)} planned local times do not exist: {missing[0]:%H:%M}β{missing[-1]:%H:%M}")
4 of 12 planned local times do not exist: 02:00β02:45
Two frames with the same local time
The data used on this page
GOES-16 ABI band 13 (10.3 Β΅m) scans of the CONUS sector from the NOAA GOES archive on AWS: file listings for 3 November 2024 (US daylight saving ended) and two scans from that night, cropped to Texas and the western Gulf of Mexico.
import re
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
import xarray as xr
from pyproj import Proj
Path("data/goes").mkdir(parents=True, exist_ok=True)
B = "https://noaa-goes16.s3.amazonaws.com/"
def scan_keys(day, hours):
keys = []
for h in hours:
text = requests.get(B, params={"list-type": 2, "prefix": f"ABI-L2-CMIPC/{day}/{h:02d}/OR_ABI-L2-CMIPC-M6C13"}, timeout=60).text
keys += re.findall(r"<Key>([^<]+)</Key>", text)
return keys
def load(key, box=(-101, -88, 25, 33)):
path = Path("data/goes", key.split("/")[-1])
if not path.exists():
path.write_bytes(requests.get(B + key, timeout=300).content)
with xr.open_dataset(path, engine="h5netcdf") as ds:
p = ds.goes_imager_projection
proj = Proj(proj="geos", h=p.perspective_point_height, lon_0=p.longitude_of_projection_origin, sweep=p.sweep_angle_axis)
x, y = ds.x.values * p.perspective_point_height, ds.y.values * p.perspective_point_height
bx, by = proj([box[0], box[1], box[0], box[1]], [box[2], box[2], box[3], box[3]])
ix, iy = (x >= min(bx)) & (x <= max(bx)), (y >= min(by)) & (y <= max(by))
return ds.CMI.values[np.ix_(iy, ix)].astype(float)
keys = scan_keys("2024/308", range(5, 9)) # 3 November 2024, 05:00β08:59 UTC
starts = pd.to_datetime([re.search(r"_s(\d{11})", k).group(1) for k in keys], format="%Y%j%H%M", utc=True)
FRAMES = pd.DatetimeIndex(starts.to_series().groupby(starts.floor("15min")).first())
print(f"{len(keys)} scans listed")
48 scans listed
Example 1: two frames with the same local time
pair = [k for k in keys if re.search(r"_s2024308(0631|0731)", k)]
fig, axes = plt.subplots(1, 2, figsize=(10, 3.6))
for ax, k in zip(axes, pair):
t = pd.to_datetime(re.search(r"_s(\d{11})", k).group(1), format="%Y%j%H%M", utc=True)
ax.imshow(load(k), cmap="gray_r", vmin=190, vmax=310)
ax.set_title(f"{t:%H:%M} UTC = {t.tz_convert('America/Chicago'):%H:%M %Z}"); ax.set_axis_off()
fig.savefig("data/same_local_time.png", dpi=120)
print("frames:", [re.search(r"_s(\d{11})", k).group(1) for k in pair])
frames: ['20243080631', '20243080731']
A timestamp without a zone is half a fact
A timestamp without a time zone is half a fact. Satellite, radar and earthquake catalogues use UTC because it never jumps. A reader's clock jumps twice a year, and by a different amount in different places. A label is right only when it says which clock it is on. The safe pattern is to keep UTC everywhere in the pipeline, use it to order, join and name frames, and convert to a named zone only when drawing the label, with the abbreviation printed. The named zone (America/Chicago) carries the rules for every change, past and future. A fixed offset (UTCβ5) is a guess that expires on the next change.
The fall-back night is the hardest case because the conversion is correct and still confusing. Two frames an hour apart both read 01:31, one CDT and one CST. Printing the abbreviation, or giving UTC alongside, is the only way the reader can tell them apart. Any code that uses the local time as a key, as a file name or for sorting will merge or reorder them.
Naive times, ambiguous hours and multiple zones
- Naive timestamps from CSV files: find out which clock they are on before converting;
tz_localize("UTC")on local times shifts everything. ambiguous=andnonexistent=intz_localizedecide how local times around the changes are resolved; set them explicitly when local input is unavoidable.- Multiple zones: an animation of the whole US needs one reference clock in the label (UTC, or one named zone stated in the title).
- Day boundaries: daily aggregates in UTC and in local time cover different hours; say which.
- Browsers show
Dateobjects in the viewer's zone; send ISO 8601 strings withZand format them deliberately.
Internal links
- How to make a GOES satellite loop β the same imagery as an animation
- How to animate an earthquake sequence β UTC catalogue times that change date in local time
FAQ
How do I convert UTC timestamps to local time in pandas?
Parse with utc=True (or tz_localize("UTC") on naive UTC values), then tz_convert("America/Chicago") or the reader's named zone. Format with %Z to show the abbreviation.
Why is my animation label off by one hour?
A fixed UTC offset was used across a daylight-saving change. Use a named time zone instead.
Why do two frames have the same time on the night the clocks go back?
Between 01:00 and 02:00 local time happens twice. Label with the zone abbreviation (CDT/CST) or UTC, and keep UTC as the frame key.
Why does tz_localize raise "Cannot infer dst time"?
It was given naive local times that occur twice, on the night the clocks go back. Pass ambiguous= to choose, or better, keep the times in UTC.