diff --git a/docs/source/user_guide/fire.ipynb b/docs/source/user_guide/fire.ipynb index 040ac5fd4..cdc70266f 100644 --- a/docs/source/user_guide/fire.ipynb +++ b/docs/source/user_guide/fire.ipynb @@ -178,26 +178,14 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "### Fireline Intensity\n", - "\n", - "Byram's fireline intensity: `I = H * w * R` where *H* is heat content (kJ/kg), *w* is fuel consumed (kg/m²), and *R* is spread rate (m/s). Output is kW/m. Fires below ~350 kW/m can be attacked by hand crews; above ~4,000 kW/m they typically need indirect attack or aerial resources." - ] + "source": "### Fireline Intensity\n\nByram's fireline intensity: `I = H * w * R` where *H* is heat content (kJ/kg), *w* is fuel consumed (kg/m²), and *R* is spread rate (m/s). Output is kW/m. Fires below ~350 kW/m can be attacked by hand crews; above ~4,000 kW/m they typically need indirect attack or aerial resources.\n\n`spread_rate_units` defaults to `'m/min'` so the output of `rate_of_spread` (below) can be passed straight in. The synthetic `spread` array here is already in m/s, so we pass `spread_rate_units='m/s'`." }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "fuel = make_da((veg * 3.0 + rng.uniform(0, 0.5, (H, W))).astype(np.float32), 'fuel')\n", - "spread = make_da((0.02 + 0.03 * rng.uniform(0, 1, (H, W))).astype(np.float32), 'spread')\n", - "\n", - "intensity_agg = fireline_intensity(fuel, spread, heat_content=18000)\n", - "\n", - "print(f\"Intensity range: {float(intensity_agg.min()):.1f} to {float(intensity_agg.max()):.1f} kW/m\")\n", - "shade(intensity_agg, cmap=['lightyellow', 'orange', 'red', 'darkred'], how='linear')" - ] + "source": "fuel = make_da((veg * 3.0 + rng.uniform(0, 0.5, (H, W))).astype(np.float32), 'fuel')\nspread = make_da((0.02 + 0.03 * rng.uniform(0, 1, (H, W))).astype(np.float32), 'spread')\n\nintensity_agg = fireline_intensity(fuel, spread, heat_content=18000,\n spread_rate_units='m/s')\n\nprint(f\"Intensity range: {float(intensity_agg.min()):.1f} to {float(intensity_agg.max()):.1f} kW/m\")\nshade(intensity_agg, cmap=['lightyellow', 'orange', 'red', 'darkred'], how='linear')" }, { "cell_type": "markdown", @@ -334,4 +322,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/examples/fire_propagation.py b/examples/fire_propagation.py index 14573f01c..199a23405 100644 --- a/examples/fire_propagation.py +++ b/examples/fire_propagation.py @@ -212,11 +212,10 @@ def make_wind(h: int, w: int, rng: np.random.Generator): fuel_consumed_da = template.copy( data=(fuel_load_kg_m2 * fuel_density).astype(np.float32), ) -ros_m_s_da = template.copy(data=(ros_vals / 60.0).astype(np.float32)) print("Computing fireline intensity & flame length ...") intensity_da = fireline_intensity( - fuel_consumed_da, ros_m_s_da, heat_content=heat_content_kj, + fuel_consumed_da, ros_da, heat_content=heat_content_kj, ) flame_len_da = flame_length(intensity_da) flame_len_vals = flame_len_da.values diff --git a/xrspatial/fire.py b/xrspatial/fire.py index d53dd10e8..974461624 100644 --- a/xrspatial/fire.py +++ b/xrspatial/fire.py @@ -533,11 +533,18 @@ def _fli_dask_cupy(fuel_data, spread_data, heat_content): def fireline_intensity(fuel_consumed_agg: xr.DataArray, spread_rate_agg: xr.DataArray, heat_content: float = 18000.0, + spread_rate_units: str = 'm/min', name: str = 'fireline_intensity') -> xr.DataArray: """Byram's fireline intensity. ``I = H * w * R`` where *H* is heat content (kJ/kg), *w* is fuel - consumed (kg/m^2), and *R* is rate of spread (m/s). + consumed (kg/m^2), and *R* is rate of spread in m/s (see + ``spread_rate_units`` for the accepted input unit). + + The spread rate is accepted in m/min by default so that the output of + :func:`rate_of_spread` can be passed straight in. Byram's equation + needs *R* in m/s, so m/min inputs are divided by 60 internally. Pass + ``spread_rate_units='m/s'`` if you already hold spread rates in m/s. Supports NumPy, CuPy, Dask with NumPy, and Dask with CuPy backed xarray DataArrays; the output backend matches the input. @@ -547,9 +554,13 @@ def fireline_intensity(fuel_consumed_agg: xr.DataArray, fuel_consumed_agg : xr.DataArray Fuel consumed per unit area (kg/m^2). spread_rate_agg : xr.DataArray - Rate of spread (m/s). + Rate of spread, in the unit given by ``spread_rate_units``. + :func:`rate_of_spread` produces m/min. heat_content : float, default=18000 Heat content of fuel (kJ/kg). + spread_rate_units : str, default='m/min' + Unit of ``spread_rate_agg``: ``'m/min'`` (matches + :func:`rate_of_spread`) or ``'m/s'``. name : str, default='fireline_intensity' Name of output DataArray. @@ -566,7 +577,7 @@ def fireline_intensity(fuel_consumed_agg: xr.DataArray, >>> from xrspatial import fireline_intensity >>> fuel = xr.DataArray(np.array([[2.0, 0.5]], dtype='f4')) >>> spread = xr.DataArray(np.array([[0.1, 0.2]], dtype='f4')) - >>> fireline_intensity(fuel, spread).values + >>> fireline_intensity(fuel, spread, spread_rate_units='m/s').values array([[3600., 1800.]], dtype=float32) """ _validate_raster(fuel_consumed_agg, func_name='fireline_intensity', @@ -576,6 +587,16 @@ def fireline_intensity(fuel_consumed_agg: xr.DataArray, validate_arrays(fuel_consumed_agg, spread_rate_agg) _validate_scalar(heat_content, func_name='fireline_intensity', name='heat_content', dtype=(int, float), min_val=0) + if spread_rate_units not in ('m/min', 'm/s'): + raise ValueError( + "fireline_intensity: spread_rate_units must be 'm/min' or " + f"'m/s', got {spread_rate_units!r}" + ) + + # Byram's equation needs R in m/s; convert from m/min when needed. + spread_data = spread_rate_agg.data.astype('f4') + if spread_rate_units == 'm/min': + spread_data = spread_data / 60.0 mapper = ArrayTypeFunctionMapping( numpy_func=_fli_cpu, @@ -585,7 +606,7 @@ def fireline_intensity(fuel_consumed_agg: xr.DataArray, ) out = mapper(fuel_consumed_agg)( fuel_consumed_agg.data.astype('f4'), - spread_rate_agg.data.astype('f4'), + spread_data, float(heat_content), ) return xr.DataArray(out, name=name, @@ -872,7 +893,9 @@ def rate_of_spread(slope_agg: xr.DataArray, Returns ------- xr.DataArray - Rate of spread in m/min (float32). + Rate of spread in m/min (float32). This is the default input unit + of :func:`fireline_intensity`, so the result can be passed there + directly. Examples -------- diff --git a/xrspatial/tests/test_fire.py b/xrspatial/tests/test_fire.py index 383627b10..c4d760f2e 100644 --- a/xrspatial/tests/test_fire.py +++ b/xrspatial/tests/test_fire.py @@ -314,7 +314,8 @@ def test_known_values(self): spread = np.array([[0.1, 0.2], [np.nan, 0.3]], dtype=np.float32) result = fireline_intensity(create_test_raster(fuel), create_test_raster(spread), - heat_content=18000) + heat_content=18000, + spread_rate_units='m/s') expected = np.array([ [18000 * 2.0 * 0.1, 18000 * 0.5 * 0.2], [np.nan, np.nan], @@ -325,11 +326,55 @@ def test_known_values(self): def test_custom_heat_content(self): fuel = np.array([[1.0]], dtype=np.float32) spread = np.array([[1.0]], dtype=np.float32) + result = fireline_intensity(create_test_raster(fuel), + create_test_raster(spread), + heat_content=20000, + spread_rate_units='m/s') + np.testing.assert_allclose(result.data[0, 0], 20000.0, rtol=1e-5) + + def test_default_units_are_m_per_min(self): + # Default spread_rate_units='m/min' divides by 60 before Byram's + # equation, so chaining rate_of_spread (m/min) is correct. + fuel = np.array([[1.0]], dtype=np.float32) + spread = np.array([[60.0]], dtype=np.float32) # m/min == 1 m/s result = fireline_intensity(create_test_raster(fuel), create_test_raster(spread), heat_content=20000) np.testing.assert_allclose(result.data[0, 0], 20000.0, rtol=1e-5) + def test_m_per_min_is_sixty_times_m_per_s(self): + fuel = np.array([[2.0, 0.5]], dtype=np.float32) + spread = np.array([[6.0, 12.0]], dtype=np.float32) + f_agg = create_test_raster(fuel) + s_agg = create_test_raster(spread) + as_min = fireline_intensity(f_agg, s_agg, spread_rate_units='m/min') + as_sec = fireline_intensity(f_agg, s_agg, spread_rate_units='m/s') + np.testing.assert_allclose(as_sec.data, as_min.data * 60.0, + rtol=1e-5) + + def test_invalid_units_raises(self): + fuel = create_test_raster(np.array([[1.0]], dtype=np.float32)) + spread = create_test_raster(np.array([[1.0]], dtype=np.float32)) + with pytest.raises(ValueError): + fireline_intensity(fuel, spread, spread_rate_units='ft/s') + + def test_chaining_rate_of_spread(self): + # The footgun: feeding rate_of_spread (m/min) straight into + # fireline_intensity must match an explicit m/min conversion. + slope = create_test_raster(np.full((3, 3), 10.0, dtype=np.float32)) + wind = create_test_raster(np.full((3, 3), 8.0, dtype=np.float32)) + moist = create_test_raster(np.full((3, 3), 0.08, dtype=np.float32)) + ros = rate_of_spread(slope, wind, moist, fuel_model=2) + fuel = create_test_raster(np.full((3, 3), 1.5, dtype=np.float32)) + chained = fireline_intensity(fuel, ros) + expected = fireline_intensity(fuel, ros, spread_rate_units='m/min') + np.testing.assert_allclose(chained.data, expected.data, rtol=1e-5, + equal_nan=True) + # And 60x the m/s interpretation of the same numbers. + as_sec = fireline_intensity(fuel, ros, spread_rate_units='m/s') + np.testing.assert_allclose(as_sec.data, chained.data * 60.0, + rtol=1e-5, equal_nan=True) + def test_nan_propagation(self): fuel = np.array([[np.nan, 1.0]], dtype=np.float32) spread = np.array([[1.0, np.nan]], dtype=np.float32)