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
Two GOES-16 infrared images of Texas and the western Gulf of Mexico, both labelled 01:31 local time: the left one is 01:31 CDT, 06:31 UTC, the right one 01:31 CST, 07:31 UTC, an hour later, with the cloud bands visibly moved. Two GOES-16 infrared images of Texas and the western Gulf of Mexico, both labelled 01:31 local time: the left one is 01:31 CDT, 06:31 UTC, the right one 01:31 CST, 07:31 UTC, an hour later, with the cloud bands visibly moved.
Two scans an hour apart on the night US clocks went back, 3 November 2024. Both are correctly labelled 01:31 Central time; only the zone abbreviation tells them apart.

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
Frame labels for GOES-16 scans on 3 November 2024 with a fixed UTCβˆ’5 offset and with the America/Chicago time zone. the same scans, two ways of labellingscan (UTC)fixed UTCβˆ’5America/Chicago05:4600:4600:46 CDT06:3101:3101:31 CDT07:0102:0101:01 CST07:3102:3101:31 CST08:4603:4602:46 CSTThe fixed offset is wrong on 8 of 16 frames, from 07:01 UTC spatialworkflow.io
A fixed offset is right until the clocks change, then an hour off for the rest of the night.

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.

Line chart of the frame label against UTC on 3 November 2024: the America/Chicago label climbs from 00:00 at 05:00 UTC to 01:55, drops back to 01:00 at 07:00 UTC and climbs to 04:00 at 10:00 UTC; the fixed UTC minus 5 label keeps climbing to 05:00. Line chart of the frame label against UTC on 3 November 2024: the America/Chicago label climbs from 00:00 at 05:00 UTC to 01:55, drops back to 01:00 at 07:00 UTC and climbs to 04:00 at 10:00 UTC; the fixed UTC minus 5 label keeps climbing to 05:00.
The local clock runs through 01:00–02:00 twice. Any code that uses the local time as a key sees that hour twice.

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

Time zone problems in map animations and their fixes. time zone problemsSymptomFixlabels an hour off after a changenamed zone, not a fixed offsettwo frames both say 01:31print CDT/CST, or UTC tooframes merged or reorderedkeep UTC as the frame keyβ€œCannot infer dst time”do not localise naive local timesplanned frames at 02:15 missingschedule in UTCUS clock changes in 2024: 10 March and 3 November spatialworkflow.io
Time zone problems and their fixes.
  • Naive timestamps from CSV files: find out which clock they are on before converting; tz_localize("UTC") on local times shifts everything.
  • ambiguous= and nonexistent= in tz_localize decide 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 Date objects in the viewer's zone; send ISO 8601 strings with Z and format them deliberately.

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.