Measuring the flaming-combustion structure that operational fire satellites average away.
Ren Dodge · Johns Hopkins University · Planet Tanager Open Data Competition, 2026
Full write-up — results first, with method, uncertainty budget and limits in the appendices. The same document in three forms:
Tanager_Subpixel_Fire_Lightning_Case_Study_Ren_Dodge.pdf |
start here — figures embedded and captioned, previews in the browser |
paper/Lightning_Case_Study.md |
the source, and it renders inline on GitHub with every figure — no download needed |
paper/Lightning_Case_Study.docx |
Word version, rebuilt by reproduce.sh. GitHub cannot preview .docx; download it to read |
The PDF is exported from the Word build rather than generated by reproduce.sh, so
after editing the markdown, re-run the build and re-export it.
Tanager-1 carries no thermal channel. It stops at 2.5 µm. Yet flaming combustion radiates enough at those wavelengths that a Planck curve can be fitted across 154 usable bands, recovering the temperature and fractional area of fires far smaller than a pixel.
On the Monroe Canyon Fire (Utah, 2025-07-24), that yields 148 subpixel fire detections at 845 ± 40 K, validated against a VIIRS overpass 2.5 minutes later.
The point is not that Tanager detects fires — VIIRS and GOES do that better, more often, and operationally. The point is what happens inside one of their pixels:
Within a single 639 × 783 m VIIRS footprint reporting one temperature and one fire radiative power, Tanager resolves up to 36 separate flaming fronts spanning 586 K and a 403× range in fractional area.
That is a measurement no operational thermal sensor can make at its own resolution, and it is the quantity fire-behavior and emissions models actually need.
Temperature changes the shape of the emitted spectrum, not just its level. Between 600 K and 1200 K the ratio B(2.4 µm)/B(1.6 µm) swings from 19.5 to 1.6. Classic two-band methods (Dozier 1981) have exactly two equations for two unknowns and no residual left to check. With 154 bands the system is heavily overdetermined, so fit quality itself becomes evidence — and a physical discriminator separates real thermal emission from bright-surface false positives:
| ΔL(2400)/ΔL(1600) | |
|---|---|
| thermal emission | 3.7 – 9.9 (matches Planck for 700–900 K) |
| reflected sunlight | 0.46 – 0.48 (declining spectrum) |
0 of 300 randomly sampled non-fire pixels survive the acceptance test.
| check | result |
|---|---|
| VIIRS/SNPP fire pixels in footprint | 6 of 6 contain Tanager detections |
| Tanager detections within 600 m of a VIIRS pixel (either overpass) | 85% |
| Detections strictly inside an SNPP footprint | 45% (stricter test, one sensor) |
| Orphan detections statistically different? | No — same shape ratio (p=0.83) and temperature (p=0.89) |
| FRP, Tanager vs VIIRS/SNPP | 115 MW vs 181 MW, same order |
The 85% and 45% figures answer different questions — proximity to either sensor versus strict containment in one — and are not in conflict.
GOES-18, scanning every 5 minutes, shows why the comparison is favorable here: at Tanager's moment the fire was detected but not quantifiable by GOES, which reported no FRP at all until 19:41 UTC, then ramped to 1022 MW by 20:16. Tanager caught it ~30 minutes before the afternoon intensification.
Stated plainly, because it matters more than the result:
- Blind to smoldering. Smoldering is flameless combustion, ~500–700 K, in duff and
organic soil; it persists for weeks and produces the CO, CH₄ and PM₂.₅ that dominate
smoke-health impact. A 600 K source needs to occupy ~10⁻¹ of a pixel before a
second thermal component becomes recoverable, versus ~10⁻³ for flaming. Synthetic
recovery testing (
fire_two_component.py) confirms the split is not retrievable at this noise level — the F-test "detects" a second component in noise-only data. This is the quantitative case for VSWIR–thermal fusion. - Emission-weighted. A one-component fit returns an effective temperature biased toward the hottest material present. There is no single "true" temperature.
- One instant, one scene. 8,201 m² of flaming area in a fire that reached 298 km² — a thin active perimeter, not a characterization of the fire.
- A rejected hypothesis is included, not hidden.
smoke_bias_*.pydocument a smoke/cloud-mask investigation that was tested and failed; the scene's aerosol field has no local variance, making the comparison unidentifiable.
git clone https://github.com/rendodge/tanager-subpixel-fire.git
cd tanager-subpixel-fire
python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt
.venv/bin/python src/fetch_data.py # ~5.4 GB; see note on Earthdata below
bash reproduce.shrequirements.txt is the direct dependency list; requirements-lock.txt holds the
exact 64-package resolution of the environment these figures were produced in
(macOS x86_64, CPython 3.10.8) — install from it to reproduce byte-for-byte.
matplotlib must stay ≥3.9 — older builds were compiled against the NumPy 1.x ABI
and fail under NumPy 2.x. Do not work around it by pinning numpy back.
VIIRS needs a free NASA Earthdata Login. Register at
https://urs.earthdata.nasa.gov/users/new, then create ~/.netrc:
machine urs.earthdata.nasa.gov login YOUR_USERNAME password YOUR_PASSWORD
and chmod 600 ~/.netrc. Tanager and GOES need no credentials.
| script | does | key output |
|---|---|---|
fetch_data.py |
downloads Tanager, GOES-18, VIIRS | data/ |
fire_qc.py |
cloud / smoke / active-fire triage | *_fireqc.png |
fire_temperature.py |
the retrieval — Planck fit, 154 bands | *_firetemp.csv |
fire_two_component.py |
can flaming and smoldering be separated? (no) | two_component_test.csv |
uncertainty_budget.py |
error budget by re-fitting under perturbations | uncertainty_budget.csv |
goes_fire_timeseries.py |
GOES-18 FRP context, 5-min cadence | goes18_*_frp.csv |
viirs_tanager_comparison.py |
subpixel structure inside true VIIRS footprints | headline figure |
make_fire_figures.py |
scene context, method, validation figures | figures/ |
build_case_study_docx.py |
renders the case study to Word with figures embedded | paper/*.docx |
burn_severity.py |
NBR + hyperspectral char/ash classes, absolute thresholds | *_burn_severity.csv |
smoke_bias_spectrum.py |
superseded — documented negative result | — |
smoke_bias_radiance.py |
the corrected version of the same test; also negative | — |
Every figure is regenerable from the script named in its docstring.
Forty days later the fire is out, and the same pipeline maps the scar
(20250902_190116_02, Sevier frame). A second September frame over Piute County tiles
the eastern part of the scar but is not reported: it overlaps Sevier by ~45 km² so the
two cannot be summed, 32% of it is cloud, and it returns the same answers.
Burned area, from basic_sr with per-pixel ground areas computed from the granule's
own geolocation:
| frame | day | valid (non-cloud) | burned (NBR < 0) | char | ash |
|---|---|---|---|---|---|
| 07-24 (active fire) | 11 | 359.5 km² | 84.0 km² (23.4%) | 0.63 km² (0.17%) | 65.4 km² |
| 09-02 (near-final scar) | 51 | 452.4 km² | 118.8 km² (26.3%) | 23.44 km² (5.18%) | 60.0 km² |
Char is time-dependent. Dark char is essentially absent while the fire still burns (0.17% of valid) and abundant forty days later (5.18%) — a factor of 30. At day 11 the entire SWIR-brightness distribution of burned pixels sits above the char threshold (p10 = 0.147, only 0.8% below); by day 51, 20.1% falls below. Smoke contributes (median SWIR brightness rises 0.190 → 0.226 across the AOD range, r = 0.165) but that ~0.036 shift cannot account for a ~0.10 gap. The straightforward reading is that fresh burn is dominated by pale ash and that ash is stripped over the following weeks, exposing char — an observation from two epochs of one fire, not a demonstrated mechanism.
Footprints differ and 22% of the September frame is cloud, so the two epochs are not directly comparable and cannot be read as scar growth without co-registration. Against the fire's official 298 km² final extent the totals are the right order.
The hyperspectral point. Char and ash have nearly identical NDVI (0.236 vs 0.236 on the Sevier frame) and NBR only 0.056 apart — but their SWIR brightness differs 5× (0.044 vs 0.218). Char is dark and spectrally flat; ash is the brightest material in the scene beyond 1.5 µm, brighter than green vegetation. A two-band index cannot separate them; 368 bands separate them trivially — NDVI and NBR are carried here as the control, to show what the standard products would report. The distinction matters because char and ash record different combustion completeness, carbon loss and erosion behavior, and because combustion completeness is what physics-based fire models predict per cell and currently cannot be scored against.
Two methodological guards, enforced in burn_severity.py:
- dNBR severity thresholds are NOT applied. The familiar 0.10/0.27/0.44/0.66 breaks are calibrated for the differenced index. No pre-fire Tanager scene of this fire exists — verified across all 276 STAC items — so dNBR is impossible, and applying its thresholds to single-date NBR would be wrong.
- Classes use absolute, physical thresholds, not percentiles. An earlier version
binned by NBR percentile; that is self-fulfilling — every scene returns ~30/40/30
regardless of what burned, and the "areas" are not measurements. Burned is now
NBR < 0(R2200 > R860, the post-fire spectral reversal), with char and ash split on absolute SWIR brightness. The two epochs now return genuinely different numbers — 23.4% vs 26.3% burned, char fractions a factor of 30 apart — instead of the same ~30/40/30 split a percentile binning returns for anything. - Areas come from
basic_sr, never ortho. The ortho grid holds 842,764 cells for 427,328 real samples on the Sevier frame — 1.97× duplication, versus 1.31× on the 07-24 frame.
No active fire remains — the real negative control. 0 of 300 random non-fire pixels is a weak test; random pixels are dark vegetation and bare soil that nothing would confuse with fire. The hard test is a whole scene of exactly what should fool a brightness threshold: bright, SWIR-dominant burn-scar pixels on ground that burned six weeks earlier. Running the retrieval on the 09-02 Sevier frame accepts 0 of 55 candidates; every one sits in the reflected-solar regime (shape ratio ≤ 0.76, never crossing the 1.5 threshold). Two VIIRS overpasses that day independently report zero fire pixels inside the frame while detecting 302 and 204 fires elsewhere in the same granules.
Tanager ships basic (swath) and ortho (gridded) products. Ortho products are nearest-neighbor resampled — measured here at 1.31 ortho cells per real swath sample — which duplicates and drops spectra and breaks the sample statistics this analysis depends on. All measurements use the basic radiance swath. Ortho is used only to draw basemaps.
Bands flagged good_wavelengths == 0 (the 1342–1438 and 1783–1967 nm water-vapor
blocks, 58 of 426) are excluded throughout.
- Tanager-1 — Planet Open Data STAC, CC-BY-4.0. Redistribution of Tanager data or derived material must carry: "Tanager STAC Data, available at www.planet.com/data/stac © 2025 Planet Labs PBC. All Rights Reserved" — prefaced with "Adapted from…" for adapted material.
- VIIRS VNP14IMG / VJ114IMG / VNP21 — NASA LP DAAC.
- GOES-18 ABI L2 FDCC — NOAA, public S3.
The Fiji/ImageJ plugin toolkit for opening and exploring Tanager scenes lives in a separate repository: tanager-hyperspectral-fiji.
Code: PolyForm Noncommercial 1.0.0
(SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0, see LICENSE).
Free to use, modify and redistribute for any noncommercial purpose — the license names research, education, public research organizations, environmental protection organizations and government institutions explicitly. Commercial use, including sale, requires a separate license: rendodge@gmail.com.
Data is not covered by that license. Tanager imagery remains CC-BY-4.0 from
Planet Labs PBC and is unrestricted by these terms — see LICENSE for the required
attribution string.