Why Are My Daily Fire Detection Counts on the Wrong Day?
On this page
Problem statement
Satellite fire detections are timestamped in UTC, and files are published by date. Neither date is the day a person on the ground lived through. GOES-East first saw the 2024 Park Fire at 15:31 on 24 July, California time. Count detections by UTC date and almost nothing happened on the 24th, with the fire's first evening and night booked to the 25th. Count them by the file they came in, and you get a third answer, because a NOAA HMS fire file is not a UTC day either. A daily chart built either way shifts the fire's activity by up to a day. A gap in one file can make a quiet day out of a busy one.
This page counts the Park Fire's GOES-East detections in NOAA's Hazard Mapping System (HMS) by file, by UTC date and by Pacific date. It shows where each boundary falls and how to put days where they belong.
Quick answer
Build a UTC timestamp from the detection's own fields, convert it to the fire's time zone, and group by the local date, never by the file:
goes = park[park.source == "GOES-EAST FDC"]
local = goes.time.dt.tz_convert("America/Los_Angeles")
days = pd.DataFrame({"by file": goes.groupby("file").size(),
"by UTC date": goes.groupby(goes.time.dt.strftime("%d %b")).size(),
"by Pacific date": goes.groupby(local.dt.strftime("%d %b")).size()}).fillna(0).astype(int)
print(days.loc["24 Jul":"27 Jul"].T.to_string())
24 Jul 25 Jul 26 Jul 27 Jul
by file 1178 8119 1331 1134
by UTC date 28 6632 3056 2013
by Pacific date 1545 7752 1757 727
What a file holds, where day one went, UTC handling
1. Check what a file holds
Look at the earliest and latest detection time in each file, by sensor:
win = hms[hms.file.isin(["24 Jul", "25 Jul", "26 Jul"])].groupby(["file", "Method"]).time.agg(["min", "max"])
print(win.apply(lambda c: c.dt.strftime("%d %b %H:%M")).to_string())
min max
file Method
24 Jul ANALYSIS 24 Jul 06:28 25 Jul 04:30
FDC 24 Jul 06:00 25 Jul 05:56
VIIRS 24 Jul 05:37 24 Jul 23:01
25 Jul ANALYSIS 25 Jul 06:09 26 Jul 08:20
FDC 25 Jul 06:00 26 Jul 05:56
VIIRS 25 Jul 06:09 25 Jul 23:27
26 Jul ANALYSIS 26 Jul 08:08 27 Jul 07:31
FDC 26 Jul 13:11 27 Jul 05:56
VIIRS 26 Jul 05:50 26 Jul 23:08
The GOES detections in a file run from 06:00 UTC on its date to 05:56 UTC the next day. The VIIRS detections stay within the UTC date. A file is a processing unit, not a calendar day in any time zone.
2. See where the fire's first day went
The first detection, in UTC and in Pacific daylight time:
first = goes.time.min()
print(f"first GOES-East detection: {first:%d %b %H:%M} UTC = {first.tz_convert('America/Los_Angeles'):%d %b %H:%M} Pacific")
evening = goes[(local.dt.date == pd.Timestamp("2024-07-24").date())]
print(f"Pacific 24 Jul detections: {len(evening):,}, of which {(evening.time.dt.day == 25).sum():,} carry a UTC date of 25 Jul")
first GOES-East detection: 24 Jul 22:31 UTC = 24 Jul 15:31 Pacific
Pacific 24 Jul detections: 1,545, of which 1,517 carry a UTC date of 25 Jul
Pacific time is UTCβ7 in summer, so everything after 17:00 local carries the next UTC date. A fire that burns hardest in the afternoon and evening has most of each day's activity split across two UTC dates.
3. Do not localise UTC times
A common mistake is to parse the time without a zone and then call tz_localize with the local zone. That labels UTC clock times as local ones, shifting every detection by seven hours:
naive = pd.to_datetime(goes.YearDay.astype(str) + goes.Time.astype(str).str.zfill(4), format="%Y%j%H%M")
wrong = naive.dt.tz_localize("America/Los_Angeles")
print(f"wrong first detection: {wrong.min():%d %b %H:%M %Z}; right: {local.min():%d %b %H:%M %Z}")
wrong first detection: 24 Jul 22:31 PDT; right: 24 Jul 15:31 PDT
Parse with utc=True (or tz_localize("UTC")), then tz_convert to the local zone.
4. Check for gaps before comparing days
A day with fewer detections may just be a day with less data. Count the hours with any GOES-East detection anywhere in North America, by Pacific date:
allg = hms[hms.source == "GOES-EAST FDC"]
hours = allg.time.dt.tz_convert("America/Los_Angeles").dt.floor("h")
cover = hours.groupby(hours.dt.strftime("%d %b")).nunique()
print(cover.loc["24 Jul":"27 Jul"].to_string())
gap = pd.date_range("2024-07-24", "2024-07-28", freq="h", tz="America/Los_Angeles", inclusive="left").difference(hours.unique())
print(f"{len(gap)} hours with no GOES-East detection anywhere: {gap.min():%d %b %H:%M} to {gap.max():%d %b %H:%M} Pacific")
time
24 Jul 24
25 Jul 23
26 Jul 18
27 Jul 24
7 hours with no GOES-East detection anywhere: 25 Jul 23:00 to 26 Jul 05:00 Pacific
The 26 July file has no GOES-East detections before 13:11 UTC, so seven overnight hours are missing. Any day that contains them, whether the Pacific date 26 July or the fire day starting 05:00 on 25 July, is undercounted and cannot be compared with its neighbours.
A fire day that starts at 05:00
The data used on this page
NOAA Hazard Mapping System fire point text files dated 23β28 July 2024, which together cover Pacific dates 24β27 July, and the Park Fire perimeter from CAL FIRE's last-five-years perimeter service, projected to California Albers (EPSG:3310).
from pathlib import Path
import geopandas as gpd
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
URL = "https://satepsanone.nesdis.noaa.gov/pub/FIRE/web/HMS/Fire_Points/Text/2024/07/"
frames = []
for d in pd.date_range("2024-07-23", "2024-07-28"):
f = Path(f"data/hms_fire{d:%Y%m%d}.txt")
if not f.exists():
f.write_bytes(requests.get(URL + f.name, timeout=120).content)
t = pd.read_csv(f, skipinitialspace=True)
t.columns = t.columns.str.strip()
frames.append(t.assign(file=f"{d:%d %b}"))
hms = pd.concat(frames, ignore_index=True)
hms["Method"] = hms.Method.str.strip()
hms["source"] = hms.Satellite.str.strip() + " " + hms.Method
hms["time"] = pd.to_datetime(hms.YearDay.astype(str) + hms.Time.astype(str).str.zfill(4), format="%Y%j%H%M", utc=True)
perims = gpd.read_file(requests.get("https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/California_Fire_Perimeters_last_5_years_View/FeatureServer/0/query",
params={"where": "FIRE_NAME='PARK' AND YEAR_=2024", "outFields": "FIRE_NAME", "outSR": 4326, "f": "geojson"}, timeout=120).text)
PARK = perims.to_crs(3310).geometry.make_valid().union_all()
pts = gpd.GeoDataFrame(hms, geometry=gpd.points_from_xy(hms.Lon, hms.Lat), crs=4326).to_crs(3310)
park = pts[pts.within(PARK.buffer(1000))]
print(f"{len(hms):,} HMS fire points in 6 files; {len(park):,} within 1 km of the Park Fire")
236,426 HMS fire points in 6 files; 26,363 within 1 km of the Park Fire
Example 1: a fire day that starts at 05:00
Fire activity is lowest around dawn, so a day running from 05:00 to 05:00 local keeps each afternoon and the night after it together:
by_hour = local.dt.hour.value_counts().sort_index()
print(f"quietest Pacific hour: {by_hour.idxmin():02d}:00 ({by_hour.min()} detections over the period)")
fire_day = (local - pd.Timedelta(hours=5)).dt.strftime("%d %b")
print(goes.groupby(fire_day).size().rename("detections, fire day 05:00β05:00 Pacific").loc["24 Jul":"27 Jul"].to_string())
quietest Pacific hour: 05:00 (142 detections over the period)
time
24 Jul 2893
25 Jul 6404
26 Jul 2264
27 Jul 296
Three clocks in play
Three clocks are in play. Satellites record UTC. Distributors split their files at whatever time suits their processing: HMS fire files break at 06:00 UTC for GOES. People, and fire agencies, live in local time, where the burning period runs from late morning into the night. A day boundary in the wrong place does not lose detections, but it moves them, and a fire's daily rhythm puts the boundary right in the middle of its most active hours.
The fix is a habit: keep every timestamp in UTC internally, convert once for reporting, and choose the reporting day deliberately. For daily totals of fire activity, a local calendar day is understandable, while a fire day starting at the quiet hour before dawn keeps each burning period whole. Whichever is used, state it, and check that every day has full coverage before comparing one with another.
Daylight saving, zones and duplicates
- Daylight saving time:
tz_convertwith a named zone (America/Los_Angeles) handles the switch; a fixed-07:00offset does not. - Fires crossing zones: a fire spanning two time zones needs one reporting zone chosen for it.
- Smoke polygons are drawn only from daylight imagery, so their files hold daytime windows within the UTC date.
- Duplicates: a few detections appear in two consecutive files (36 here); drop duplicates on location, time and satellite.
- GOES-West vs GOES-East: both are in HMS, with different scan times and pixel footprints; count them separately.
Internal links
- How to use NOAA HMS satellite fire points β the data, by sensor
- How to map a wildfire's spread from GOES detections β progression bands cut at 05:00 and 17:00 local
FAQ
What time zone are satellite fire detections in?
UTC. HMS, FIRMS and the GOES fire product all record times in UTC.
Does an HMS fire file cover one UTC day?
No. In the July 2024 files the GOES detections run from 06:00 UTC on the file's date to 05:56 UTC the next day. Group by the detection times, not the file.
How do I convert detection times to local time in pandas?
Parse with pd.to_datetime(..., utc=True) and then .dt.tz_convert("America/Los_Angeles"). Do not use tz_localize on times that are already UTC.
What is a fire day?
A reporting day that starts at the quietest hour of fire activity, usually around dawn, so each afternoon and the night after it fall on the same day.