geeViz.fireLib

Wildland fire modeling on Earth Engine.

Earth Engine is a lazy, tile-parallel, stateless engine. Fire spread is inherently sequential — a front advances over many timesteps, each depending on the last. Most Earth Engine fire projects founder on that seam, so this package draws the line explicitly rather than discovering it late:

Tier 1 — everything pixel-wise, in Earth Engine. Fuels assembly, terrain derivatives, fire-danger climatology, per-pixel fire behavior, risk algebra. All embarrassingly parallel; this is where the value is.

Tier 2 — cost-distance propagation, in Earth Engine. Arrival-time surfaces via ee.Image.cumulativeCost, which is the Eikonal/minimum- travel-time solution and needs no timestep loop at all. Documented ceiling: it is isotropic.

Tier 3 — the real simulators, outside. FSim, FlamMap, FARSITE, ELMFIRE. This package writes their inputs and reads their outputs. It does not reimplement them.

The propagation insight worth stating up front, because it is what makes Tier 2 practical:

cost    = ee.Image(1).divide(ros)               # seconds per meter
arrival = cost.cumulativeCost(ignition, 50000)  # seconds to each pixel
frames  = [arrival.lte(t * dt) for t in range(1, 101)]

Least-accumulated-cost from a source is minimum travel time. A hundred animation frames are a hundred thresholds of one image, not a hundred iterations. Doing it as nested neighborhood operations instead would require a 100-pixel halo on every tile, because each one expands the required footprint by a pixel.

What this package will not do, stated so it can be designed around:

  • No true elliptical head-fire spread. cumulativeCost assigns cost per pixel, not per edge, so it cannot express “cheap downwind, expensive upwind”. Chaining short wind-blocks approximates directional behavior; it does not reproduce Huygens wavelets.

  • No coupled fire-atmosphere behavior. Plume dynamics and downdraft-driven runs are WRF-Fire and QUIC-Fire territory.

  • No spotting. Ember transport is stochastic and cost-distance cannot express it.

  • No replacement for a project-level FSim run.

geeViz.fireLib.landfire_fuels(bands=('FBFM40', 'CBH', 'CBD', 'CC', 'CH'), *, region: Any = None)[source]

Assemble a multi-band LANDFIRE fuels image.

Parameters:
  • bands – Keys from FUEL_ASSETS. The default is exactly the set a Rothermel surface run plus a Van Wagner crown-fire check needs: a surface fuel model, canopy base height, canopy bulk density, canopy cover, and canopy height.

  • region – Optional geometry to clip to. Clipping early is usually the right call for an AOI-scale analysis — it keeps later reductions from touching tiles they will discard anyway.

Returns:

ee.Image with one band per requested key, named by that key.

Note

Bands are not unit-converted here. LANDFIRE ships canopy base height and canopy height in metres x 10, and canopy bulk density in kg/m3 x 100, precisely so they can be stored as integers. behavior() applies the scaling where the physics needs real units — doing it here would mean two places could disagree about whether a value was already scaled, which is the sort of error that produces plausible numbers rather than obvious ones.

geeViz.fireLib.terrain_layers(dem_asset: str = 'USGS/3DEP/10m', *, region: Any = None)[source]

Slope, aspect and terrain derivatives for fire behavior.

Parameters:
  • dem_asset – DEM to derive from. 3DEP 10 m for CONUS; pass "USGS/SRTMGL1_003" for near-global 30 m coverage.

  • region – Optional clip geometry.

Returns:

ee.Image with elevation, slope (degrees), aspect (degrees), northness, eastness, and slope_tan (tangent of slope, which is the form Rothermel’s slope factor actually consumes).

Note

northness / eastness exist because raw aspect must never be fed to a model or a statistic. 359 degrees and 1 degree are adjacent on the ground and maximally distant numerically; averaging them yields 180, which points the wrong way. Decomposing to cos/sin makes the circular quantity behave like two linear ones.

geeViz.fireLib.fuel_model_params(fbfm: int) → Dict[str, Any][source]

Rothermel bed parameters for a Scott & Burgan fuel model number.

Parameters:

fbfm – FBFM40 code, e.g. 102 for GR2 or 165 for TU5.

Returns:

Dict of bed properties plus name and burnable.

Raises:

KeyError – The model is not in the table. Deliberate — silently substituting a default would return a plausible spread rate for a fuel bed nobody described, which is worse than an error because nothing downstream would question it.

geeViz.fireLib.fuel_param_image(fbfm_band, param: str)[source]

Turn a fuel-model raster into a raster of one bed parameter.

Uses remap, which is the right primitive here: the mapping is a lookup with no ordering meaning, so arithmetic on the model number itself would be nonsense (model 165 is not “more” than 102).

Parameters:
  • fbfm_band – Single-band ee.Image of FBFM40 codes.

  • param – Key from fuel_model_params(), e.g. "w_1h".

Returns:

ee.Image of that parameter, masked where the fuel model is not in the table — masked rather than zero-filled, because zero is a meaningful fuel load and an unmapped pixel is not the same as a pixel with no fuel.

geeViz.fireLib.fuel_coverage(fuels, region, *, scale: int = 90, fbfm_band: str = 'FBFM40') → Dict[str, Any][source]

What fraction of an area the parameter table can actually model.

Run this before trusting any spread result over a new area. _FBFM40_PARAMS is a verified subset, not the full Scott & Burgan 40, and an unlisted model contributes a masked pixel — so a landscape can come back looking calm simply because a third of it was unmodellable. The gap is invisible in the output image and obvious here.

Measured on a real Oregon test area: 91.4% covered, with the 8.6% shortfall dominated by GS3 (123).

Returns:

Dict with covered_fraction, total_pixels, and missing — a list of (fuel_model, pixels, fraction) sorted by how much of the area each accounts for, which is the priority order for extending the table.

geeViz.fireLib.rate_of_spread(fuels, terrain, *, wind_speed_20ft, moisture_1h=0.06, moisture_live=1.5, fbfm_band: str = 'FBFM40', wind_adjustment: float = 0.4)[source]

Rothermel surface rate of spread, ft/min, as an ee.Image.

Parameters:
  • fuels – Image carrying the fuel-model band (see landfire_fuels()).

  • terrain – Image carrying slope_tan (see terrain_layers()).

  • wind_speed_20ft – 20-ft wind speed in mi/h, as a number or an ee.Image. GRIDMET’s vs is 10 m in m/s — convert before passing it, or the result is wrong by roughly a factor of two and still looks reasonable.

  • moisture_1h – Dead 1-hour fuel moisture, fraction (0.06 = 6%). GRIDMET ships fm100/fm1000 as percentages.

  • moisture_live – Live fuel moisture, fraction. 1.50 is a typical growing-season value; 0.60 or below is critically dry.

  • fbfm_band – Name of the fuel-model band in fuels.

  • wind_adjustment – Factor converting 20-ft wind to midflame wind. 0.4 is a common sheltered-surface default; unsheltered grass is nearer 0.6 and dense timber nearer 0.1-0.2. This single number moves rate of spread more than almost any other input, which is why it is an explicit argument rather than a constant.

Returns:

ee.Image band ros_ft_min, masked where the fuel model is not in the parameter table and forced to zero on non-burnable models.

geeViz.fireLib.ros_metric(ros_ft_min)[source]

Convert rate of spread from ft/min to m/s.

Kept as an explicit edge conversion. Rothermel’s published coefficients are unit-bearing, so the calculation stays in English units throughout and converts exactly once, here.

geeViz.fireLib.flame_length(ros_ft_min, i_r=None, *, residence_time_min: float = 0.5)[source]

Byram flame length in feet from rate of spread.

Byram: FL = 0.45 * I^0.46 with fireline intensity I in BTU/ft/s. Intensity is reaction intensity times residence time times spread rate.

Flame length is what most risk products key on – the 4 ft and 8 ft thresholds in Wildfire Risk to Communities are direct suppression interpretations: under 4 ft is generally attackable by hand crews, over 8 ft implies crowning or spotting and effectively rules out direct attack.

geeViz.fireLib.crown_fire_initiation(fuels, flame_length_ft, *, cbh_band: str = 'CBH', cbh_scale: float = 0.1)[source]

Van Wagner crown-fire initiation: does the surface fire torch?

A surface fire transitions to crown fire when its intensity is sufficient to ignite the canopy base. The controlling geometry is canopy base height — the vertical gap between surface flames and the lowest live crown fuel.

Parameters:

cbh_scale – LANDFIRE stores canopy base height as metres x 10 so it can be an integer raster, so 0.1 converts to metres. Getting this wrong by a factor of ten produces a canopy 10x too high and a landscape that never torches.

Returns:

ee.Image band crown_initiation — 1 where the surface fire is expected to reach the canopy base, 0 otherwise.

geeViz.fireLib.travel_time(ros_m_s, ignition, *, max_distance_m: int = 50000, min_ros_m_s: float = 0.0001, geodetic: bool = True)[source]

Fire arrival time, in seconds, from an ignition source.

Parameters:
  • ros_m_s – Rate of spread in metres per second as an ee.Image. Use ros_metric() to convert from Rothermel’s ft/min.

  • ignition – ee.Image whose non-zero pixels are sources, or an ee.Geometry / ee.FeatureCollection to rasterize.

  • max_distance_m – Search radius. Also the cost ceiling — pixels beyond it are masked rather than assigned a huge time.

  • min_ros_m_s – Floor applied to the spread rate before inverting. Non-burnable fuel has ROS exactly zero, and 1/0 is infinite cost, which is correct but propagates as a masked pixel that can sever otherwise-connected paths. The floor makes non-fuel effectively impassable (a very large but finite cost) while keeping the cost surface defined.

Returns:

ee.Image band arrival_s — seconds for the fire to reach each pixel.

Note

Cost-unit question, now settled empirically: cumulativeCost accumulates cost per metre, so a cost band in seconds-per- metre yields arrival times in seconds. Verified by doubling a uniform spread rate and confirming arrival times halved exactly — the feared factor-of-30 per-pixel interpretation is ruled out.

Accuracy against the analytic answer, uniform 1 m/s at 44.2 deg N: 0.997 due east, 0.993 due north, 1.053 on the diagonal. The diagonal residual is grid-path overhead and is not worth correcting for; the directional bias that was worth fixing is described on GEODETIC_DISTANCE.

geeViz.fireLib.isochrones(arrival_s, *, n_frames: int = 100, step_seconds: float | None = None, total_seconds: float | None = None)[source]

Turn one arrival-time surface into an animation-ready collection.

This is the payoff of not iterating. Each frame is a threshold of the same image, so a hundred frames cost a hundred cheap comparisons rather than a hundred rounds of neighborhood growth.

Parameters:
  • arrival_s – Output of travel_time().

  • n_frames – Number of frames.

  • step_seconds – Seconds per frame. Defaults to total_seconds / n_frames.

  • total_seconds – Burn period. Defaults to 24 hours.

Returns:

ee.ImageCollection of masked burned-extent frames, each with t_seconds and t_hours properties — ready for geeViz’s existing timelapse machinery.

geeViz.fireLib.spread_with_wind_blocks(fuels, terrain, blocks, ignition, *, ros_fn=None, max_distance_m: int = 50000)[source]

Chained cost-distance spread across changing wind.

Iterates coarsely – one cumulativeCost per wind period rather than one per timestep. A five-day fire at six-hourly wind is about twenty calls, which Earth Engine handles comfortably, and it captures the wind shifts that drive large runs. Within a block the spread is still isotropic.

Parameters:
  • blocks – Sequence of dicts, each with wind_speed (mi/h), optionally moisture_1h, and duration_s.

  • ros_fn – Callable (fuels, terrain, **block) -> ee.Image in m/s. Defaults to Rothermel via behavior.

Returns:

ee.Image band burned — final perimeter after all blocks.

geeViz.fireLib.transmission_matrix(fuels, terrain, ignitions, units, *, wind_speed_20ft=8.0, burn_period_s: float = 86400, scale: int = 30, max_distance_m: int = 50000)[source]

Which unit’s ignitions burn which unit’s land.

Cross-boundary fire transmission, and it falls out of the same primitive nearly free. Every ignition is independent, so this maps onto Earth Engine cleanly where a timestep loop does not.

Parameters:
  • ignitions – ee.FeatureCollection of ignition points, each carrying the property named by source_prop.

  • units – ee.FeatureCollection of ownership or community polygons with a unit property.

Returns:

ee.FeatureCollection, one feature per (ignition, unit) pair carrying burned area in hectares.

geeViz.fireLib.calibrate_cost_units(ros_m_s, ignition, known_distance_m: float, known_time_s: float, *, scale: int = 30)[source]

Check whether arrival times come back in the units you expect.

Runs travel_time() over a landscape of uniform spread rate, where the answer is known analytically: reaching a point d metres away at r m/s must take d / r seconds.

Returns a dict with the expected and observed times and their ratio. A ratio near the pixel size means cost is accumulating per pixel rather than per metre — a factor-of-30 error at 30 m that leaves every number plausible and every number wrong. Run this once against a fire with known progression before publishing anything derived from arrival times.

Modules

behavior

Per-pixel fire behavior — Rothermel surface spread and its consequences.

fuels

Fuels and terrain assembly — the layer everything else stands on.

spread

Fire spread without a timestep loop.