How to Calculate Fire Radiative Energy From GOES in Python

On this page

Problem statement

Fire radiative power (FRP) tells you how hard a fire is burning at one moment. Integrated over time it becomes fire radiative energy (FRE), the total heat the fire radiated. FRE is proportional to the mass of vegetation burned: Wooster and colleagues measured about 0.368 kg of dry fuel per megajoule. That makes it the route from satellite fire detections to fuel consumption and smoke emissions, the basis of operational emission inventories such as GFAS.

The arithmetic is one call to np.trapezoid. The work is in the inputs. How often the satellite looked, which pixels carry no FRP, and which satellite was used all change the answer. This page integrates GOES-18 and GOES-16 FRP over the first two and a half days of the 2024 Park Fire and measures each effect.

Quick answer

Sum FRP over the fire's pixels in each scan, then integrate over time. MW Γ— s = MJ:

s = G18.frp_mw
seconds = (s.index - s.index[0]).total_seconds().to_numpy()
fre_mj = np.trapezoid(s.to_numpy(), seconds)
print(f"{len(s)} GOES-18 scans, {s.index[0]:%d %b %H:%M} to {s.index[-1]:%d %b %H:%M} UTC")
print(f"FRE {fre_mj / 1e6:,.0f} TJ; fuel burned β‰ˆ {fre_mj * 0.368 / 1e9:.2f} million tonnes (0.368 kg/MJ)")
612 GOES-18 scans, 24 Jul 21:01 to 26 Jul 23:56 UTC
FRE 4,118 TJ; fuel burned β‰ˆ 1.52 million tonnes (0.368 kg/MJ)
Line chart of fire radiative power every five minutes from 24 to 26 July 2024 for the Park Fire, from GOES-18 in red and GOES-16 in blue, with cumulative fire radiative energy as dashed lines: GOES-18 FRP peaks at 66 gigawatts on the afternoon of 25 July and its energy reaches 4,118 terajoules; GOES-16 tracks the same daily cycle at lower values and reaches 2,682 terajoules. Line chart of fire radiative power every five minutes from 24 to 26 July 2024 for the Park Fire, from GOES-18 in red and GOES-16 in blue, with cumulative fire radiative energy as dashed lines: GOES-18 FRP peaks at 66 gigawatts on the afternoon of 25 July and its energy reaches 4,118 terajoules; GOES-16 tracks the same daily cycle at lower values and reaches 2,682 terajoules.
The same fire seen by two satellites. Integrated over two and a half days, GOES-16's lower view adds up to 35% less energy.

A zero-filled series, sampling effects, missing FRP

1. Build the per-scan series with zeros

A scan with no fire pixels is a measurement of zero, not a missing value. Reindex to every scan time before integrating, or the integral will bridge quiet periods with straight lines:

print(f"scans with no fire pixels: {(G18.frp_mw == 0).sum()} of {len(G18)}; longest gap between scans: {G18.index.to_series().diff().max()}")
peak = G18.frp_mw.idxmax()
print(f"peak FRP {G18.frp_mw.max() / 1000:.1f} GW at {peak.tz_convert('America/Los_Angeles'):%d %b %H:%M} Pacific")
scans with no fire pixels: 12 of 612; longest gap between scans: 0 days 00:05:00.100000
peak FRP 65.6 GW at 25 Jul 14:31 Pacific

2. Measure the effect of the sampling interval

GOES-18 scans the continental US every 5 minutes. Many workflows keep one scan in 3 or one an hour to save downloads:

def fre_tj(series):
    sec = (series.index - series.index[0]).total_seconds().to_numpy()
    return np.trapezoid(series.to_numpy(), sec) / 1e6

full = fre_tj(G18.frp_mw)
for label, keep in (("every 5 min", np.ones(len(G18), bool)),
                    ("every 15 min", G18.index.minute % 15 < 5), ("hourly", G18.index.minute < 5)):
    v = fre_tj(G18.frp_mw[keep])
    print(f"{label:12s}: {v:,.0f} TJ ({v / full - 1:+.1%})")
every 5 min : 4,118 TJ (+0.0%)
every 15 min: 4,081 TJ (-0.9%)
hourly      : 3,942 TJ (-4.3%)

Coarse sampling misses short peaks and, with trapezoids, cuts corners. Here it costs little at 15 minutes and a few per cent at an hour; for a fire with sharper bursts it can cost more.

Detail of GOES-18 FRP from 11:00 to 19:00 Pacific on 25 July: the five-minute series rises and falls in short peaks up to 66 gigawatts, and straight lines between hourly scans cut across them, missing the area under the highest peaks. Detail of GOES-18 FRP from 11:00 to 19:00 Pacific on 25 July: the five-minute series rises and falls in short peaks up to 66 gigawatts, and straight lines between hourly scans cut across them, missing the area under the highest peaks.
Why hourly sampling loses energy: straight lines between hourly scans miss the short peaks in between.

3. Count the pixels with no FRP

Some fire pixels have no FRP value: saturated, cloudy or otherwise unfit for the retrieval. Their energy is missing from the sum:

nan_share = DET18.groupby("category").frp_mw.apply(lambda s: s.isna().mean())
counts = DET18.category.value_counts()
print(pd.DataFrame({"detections": counts, "without FRP": nan_share.reindex(counts.index)}).to_string(formatters={"without FRP": "{:.0%}".format}))
print(f"overall: {DET18.frp_mw.isna().mean():.0%} of fire-pixel detections carry no FRP")
                                                   detections without FRP
category                                                                 
temporally_filtered_good_fire_pixel                     15272          0%
temporally_filtered_cloud_contaminated_fire_pixel        2793        100%
temporally_filtered_high_probability_fire_pixel          1111          0%
temporally_filtered_saturated_fire_pixel                  315        100%
temporally_filtered_low_probability_fire_pixel            130        100%
temporally_filtered_medium_probability_fire_pixel          16          0%
good_fire_pixel                                             3          0%
medium_probability_fire_pixel                               1          0%
low_probability_fire_pixel                                  1        100%
overall: 16% of fire-pixel detections carry no FRP

4. Compare two satellites

GOES-16 (GOES-East, over 75.2Β° W) sees the same fire at a much lower angle than GOES-18 (GOES-West, over 137.2Β° W):

for name, det, series in (("GOES-18", DET18, G18), ("GOES-16", DET16, G16)):
    print(f"{name}: {len(det):,} fire-pixel detections, peak {series.frp_mw.max() / 1000:.1f} GW, FRE {fre_tj(series.frp_mw):,.0f} TJ")
print(f"GOES-16 / GOES-18 FRE: {fre_tj(G16.frp_mw) / fre_tj(G18.frp_mw):.2f}")
GOES-18: 19,642 fire-pixel detections, peak 65.6 GW, FRE 4,118 TJ
GOES-16: 10,790 fire-pixel detections, peak 53.0 GW, FRE 2,682 TJ
GOES-16 / GOES-18 FRE: 0.65

From further away, GOES-16's pixels are larger, so a fire must be hotter or bigger to stand out, and fewer pixels are flagged. FRE from the satellite with the better view is the more complete; combining satellites needs a correction, not an average.

Radiated energy per fire day

The data used on this page

GOES-18 and GOES-16 ABI L2 Fire - Hot Spot Characterization (ABI-L2-FDCC, CONUS sector, every 5 minutes) from NOAA's archives on AWS, 21:00 UTC on 24 July to 23:59 UTC on 26 July 2024, for a box around the Park Fire (39.6–40.5Β° N, 121.3–122.3Β° W). The fuel conversion factor of 0.368 Β± 0.015 kg/MJ is from Wooster et al. (2005).

import re
from pathlib import Path

import numpy as np
import pandas as pd
import requests
import xarray as xr
from pyproj import Proj


def goes_fire(bucket):
    """Download every CONUS FDC scan for the period and return fire-pixel detections and a per-scan FRP series."""
    B = f"https://{bucket}.s3.amazonaws.com/"
    folder = Path("data", bucket); folder.mkdir(parents=True, exist_ok=True)
    files = []
    for day in (206, 207, 208):
        for h in range(21 if day == 206 else 0, 24):
            text = requests.get(B, params={"list-type": 2, "prefix": f"ABI-L2-FDCC/2024/{day}/{h:02d}/"}, timeout=60).text
            for k in re.findall(r"<Key>([^<]+)</Key>", text):
                f = folder / k.rsplit("/", 1)[1]
                if not f.exists():
                    f.write_bytes(requests.get(B + k, timeout=120).content)
                files.append(f)
    with xr.open_dataset(files[0], engine="h5netcdf") as ds:
        p = ds.goes_imager_projection
        meanings = dict(zip(ds.Mask.attrs["flag_values"].tolist(), ds.Mask.attrs["flag_meanings"].split()))
        proj = Proj(proj="geos", h=p.perspective_point_height, lon_0=p.longitude_of_projection_origin, sweep=p.sweep_angle_axis,
                    a=p.semi_major_axis, b=p.semi_minor_axis)
        x, y = ds.x.values * p.perspective_point_height, ds.y.values * p.perspective_point_height
    codes = [c for c, n in meanings.items() if n.endswith("fire_pixel") and "no_fire" not in n]
    bx, by = proj([-122.3, -121.3, -122.3, -121.3], [39.6, 39.6, 40.5, 40.5])
    IX, IY = (x >= min(bx)) & (x <= max(bx)), (y >= min(by)) & (y <= max(by))
    rows, times = [], []
    for f in files:
        with xr.open_dataset(f, engine="h5netcdf") as ds:
            t = pd.Timestamp(ds.attrs["time_coverage_start"].rstrip("Z"), tz="UTC")
            m, frp = ds.Mask.values[np.ix_(IY, IX)], ds.Power.values[np.ix_(IY, IX)]
        times.append(t)
        for j, i in zip(*np.nonzero(np.isin(m, codes))):
            rows.append((t, meanings[int(m[j, i])], float(frp[j, i])))
    det = pd.DataFrame(rows, columns=["time", "category", "frp_mw"])
    series = det.groupby("time").frp_mw.sum().reindex(sorted(times), fill_value=0.0).to_frame()   # no fire = 0 MW
    return det, series


DET18, G18 = goes_fire("noaa-goes18")
DET16, G16 = goes_fire("noaa-goes16")
print(f"GOES-18: {len(G18)} scans, {len(DET18):,} fire-pixel detections; GOES-16: {len(G16)} scans, {len(DET16):,}")
GOES-18: 612 scans, 19,642 fire-pixel detections; GOES-16: 612 scans, 10,790

Example 1: energy per fire day

Integrate within days that start at 05:00 Pacific, near the daily low of fire activity:

local = G18.frp_mw.tz_convert("America/Los_Angeles")
day = (local.index - pd.Timedelta(hours=5)).strftime("%d %b")
for d, part in local.groupby(day):
    print(f"fire day {d}: {fre_tj(part):,.0f} TJ over {len(part)} scans, from {part.index[0]:%d %b %H:%M} to {part.index[-1]:%d %b %H:%M}")
fire day 24 Jul: 870 TJ over 180 scans, from 24 Jul 14:01 to 25 Jul 04:56
fire day 25 Jul: 2,456 TJ over 288 scans, from 25 Jul 05:01 to 26 Jul 04:56
fire day 26 Jul: 786 TJ over 144 scans, from 26 Jul 05:01 to 26 Jul 16:56

The first full fire day, from 05:00 on 25 July, released 2,456 of the 4,118 TJ, about 60%. The last day here is only a morning and afternoon, because the data stop at 17:00 Pacific on 26 July.

From radiant power to energy

FRP is the radiant heat leaving the fire, retrieved from how much brighter a pixel is at 3.9 Β΅m than its surroundings. Integrating it over time gives the energy radiated. Field and laboratory burns show that this energy scales with the dry mass burned, at roughly the same rate across fuel types, which is why FRE is used to estimate fuel consumption and, with emission factors, smoke. It counts radiated energy only, a fraction of the total heat released, but the conversion factor already accounts for that.

From GOES fire radiative power to fuel burned, for the Park Fire, 24 to 26 July 2024. from power to fuelFRP per pixelMW, 19,642detectionssum per scan612 scans, 5 minapartintegrate overtimeMW Γ— s = MJFRE4,118 TJΓ— 0.368 kg/MJβ‰ˆ 1.52 million tfuelGOES-18; 16% of fire pixels had no FRP and add nothing spatialworkflow.io
From fire radiative power to fuel burned, for GOES-18 over the Park Fire.

Geostationary FRP is ideal for integration because it samples often enough to follow the daily cycle. Its weakness is sensitivity: small or cool fires, and fires seen at a steep angle, fall below detection, and their energy is never counted. Polar-orbiting VIIRS and MODIS see smaller fires but only a few times a day. Inventories combine the two by calibrating the geostationary record against the polar one.

Units, boxes and conversion factors

Fire radiative energy problems and fixes. FRE problemsSymptomFixquiet hours bridged by linesreindex scans; no fire = 0 MWtotal 4% lowhourly scans: use every 5 or 15minsaturated or cloudy pixelsno FRP: report the share missingGOES-16 35% below GOES-18use the better view; calibrate,not averageGOES FDC over the Park Fire, 24–26 July 2024 spatialworkflow.io
Fire radiative energy problems and fixes.
  • Units: FRP is in MW; MW Γ— seconds = MJ; 10⁢ MJ = 1 TJ.
  • Box vs fire: everything in the box counts; check that no other fire is inside it, or mask with a perimeter.
  • Missing FRP: report the share of fire pixels without a value beside any total; it is an unknown, not a zero.
  • Conversion factor: 0.368 kg/MJ is for dry matter; fuel moisture and fuel type add uncertainty of tens of per cent.
  • Fire day vs UTC day: a UTC day cuts the afternoon peak in two; see why daily fire counts land on the wrong day.

FAQ

What is fire radiative energy?

The total radiant heat released by a fire over a period, the time integral of fire radiative power. It is proportional to the mass of fuel burned.

How do I convert FRE to biomass burned?

Multiply by about 0.368 kg of dry matter per MJ (Wooster et al., 2005). Emission factors then turn the burned mass into smoke and gases.

Does the scan interval matter for FRE?

A little at 15 minutes and more at hourly sampling: here 15-minute scans changed the total by under 1% and hourly scans by about 4%.

Why do GOES-16 and GOES-18 give different FRP for the same fire?

They see it from different angles and with different pixel sizes, so they detect different numbers of fire pixels. Use the satellite with the better view, or calibrate one against the other.