geeViz.fireLib.spread

Fire spread without a timestep loop.

The instinct when modeling spread on a raster is to iterate: dilate the burned area, advance a timestep, repeat a few hundred times. On Earth Engine that is the expensive path, and not by a little. Each neighborhood operation expands the footprint a tile must fetch by one pixel, so a hundred nested ones need a hundred-pixel halo on every tile. The engine will either crawl or refuse.

The native primitive does it in one call. ee.Image.cumulativeCost computes, for each pixel, the minimum accumulated cost to reach it from a source. Set cost to time per unit distance and least-accumulated-cost is minimum travel time — the same quantity FlamMap’s MTT computes, and the solution to the Eikonal equation |grad T| = 1 / ROS:

cost    = ee.Image(1).divide(ros)
arrival = cost.cumulativeCost(ignition, maxDistance=50000)

The hundred animation frames people wanted from the loop are a hundred thresholds of that single image. No halo growth, no graph depth, trivially parallel.

The limitation, stated plainly: this is isotropic. cumulativeCost assigns cost per pixel, not per edge, so it cannot express “cheap downwind, expensive upwind”. A single call gives fuel- and terrain-modulated spread that is directionally neutral — no elliptical head-fire elongation.

spread_with_wind_blocks() works around that by iterating coarsely instead of finely: chain one call per wind period, each using that period’s wind. Twenty chained calls for a five-day fire is very manageable, and it captures wind shifts, which is what drives the large runs operationally. Within-block anisotropy is still lost. That trade is fine for planning and risk; it is not fine for operational head-fire prediction, and the difference should be stated in any product built on this.

Module Attributes

DEFAULT_MAX_DISTANCE_M

Guards a runaway cumulativeCost.

GEODETIC_DISTANCE

Whether cumulativeCost measures distance on the curved Earth rather than in the map projection's plane.

Functions

calibrate_cost_units(ros_m_s, ignition, ...)

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

isochrones(arrival_s, *[, n_frames, ...])

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

spread_with_wind_blocks(fuels, terrain, ...)

Chained cost-distance spread across changing wind.

transmission_matrix(fuels, terrain, ...[, ...])

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

travel_time(ros_m_s, ignition, *[, ...])

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

geeViz.fireLib.spread.DEFAULT_MAX_DISTANCE_M = 50000

Guards a runaway cumulativeCost. It bounds the search, so an unreachable target does not walk the whole landscape.

geeViz.fireLib.spread.GEODETIC_DISTANCE = True

Whether cumulativeCost measures distance on the curved Earth rather than in the map projection’s plane.

This must stay True for anything in geographic coordinates, and the reason is measurable. With geodeticDistance=False on an EPSG:4326 image, a degree of longitude is treated as the same ground distance as a degree of latitude. It is not — it is shorter by cos(latitude) — so east-west travel is inflated by 1 / cos(latitude).

Measured at 44.2 deg N with a uniform 1 m/s spread rate, comparing modeled arrival time against the analytic answer:

1/cos(44.2 deg) = 1.394, which is the 1.386 almost exactly. A fire would have spread 39% too slowly east-west and correctly north-south — a systematic directional error that looks entirely plausible on a map and gets worse toward the poles (2x at 60 deg N).

The residual ~5% on the diagonal is the grid-path overhead: cost accumulates between pixel centres, so the discrete shortest path is slightly longer than the straight line.

geeViz.fireLib.spread.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.spread.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.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.spread.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.spread.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.