From 561e232758b9acb6106c4713a0b6ab4c81632ed7 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 10:35:38 -0600 Subject: [PATCH] Add create_canopy_fuel_grid_from_inventory to v2 SDK Wrap the create_inventory_canopy_grid endpoint in the hand-written grids surface, following the create__grid_from_ convention. The function derives canopy fuel bands (cbd, cbh, chm, cc, cfl) from a completed tree inventory. The many method choices map onto the generated union models through friendly kwargs with sensible defaults: biomass source (allometry vs inventory column), available-fuel reduction, species inclusion, crown-class adjustment, min tree height, vertical/horizontal distribution, layer depth, max-crown-radius source, and per-band cbd/ cbh/chm/cc reduction methods (a method name string or a prebuilt method object for non-default parameters). --- fastfuels_sdk/v2/grids.py | 328 ++++++++++++++++++++++++++++++++++++++ tests/v2/test_grids.py | 170 ++++++++++++++++++++ 2 files changed, 498 insertions(+) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 07dd0a9..cd267b8 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -25,6 +25,7 @@ create_fbfm40_lookup, create_geotiff_upload, create_grid_export, + create_inventory_canopy_grid, create_landfire_canopy, create_landfire_fbfm13, create_landfire_fbfm40, @@ -47,7 +48,29 @@ ) from fastfuels_sdk.v2.client_library.models import ( Grid as GridModel, + AllometryCanopyBiomassSource, ApplyGridModificationsRequest, + CanopyAllometryMaxCrownRadiusSource, + CanopyAvailableFuel, + CanopyBiomassEquations, + CanopyBranchwood, + CanopyBranchwoodSizePartition, + CanopyCbdLoadOverDepth, + CanopyCbdRunningMean, + CanopyCbhMean, + CanopyCbhMinimum, + CanopyCbhPercentile, + CanopyCcCoverFraction, + CanopyCcCrownOverlap, + CanopyCcCrownUnion, + CanopyChmHeightPercentile, + CanopyCrownWidthEquations, + CanopyFuelcalcCrownClassAdjustment, + CanopyHorizontalDistribution, + CanopyNoCrownClassAdjustment, + CanopyProfileThreshold, + CanopySpeciesInclusion, + CanopyVerticalDistribution, ComposeAttributeCondition, ComposeCompute, ComposeInput, @@ -59,6 +82,7 @@ CreateFbfm13LookupRequest, CreateFbfm40LookupRequest, CreateGeoTIFFUploadRequest, + CreateInventoryCanopyRequest, CreateLandfireCanopyRequest, CreateLandfireFbfm13Request, CreateLandfireFbfm40Request, @@ -87,6 +111,9 @@ GridExportFormat, GridSortField, InlineCompute, + InventoryCanopyBand, + InventoryColumnCanopyBiomassSource, + InventoryColumnMaxCrownRadiusSource, JobStatus, LandfireCanopyFuelBand, LandfireCanopyVersion, @@ -122,6 +149,7 @@ "create_topography_grid_from_3dep", "create_topography_grid_from_landfire", "create_canopy_fuel_grid_from_landfire", + "create_canopy_fuel_grid_from_inventory", "create_canopy_height_grid_from_meta", "create_canopy_height_grid_from_naip_chm", "create_canopy_height_grid_from_point_cloud", @@ -1060,6 +1088,306 @@ def create_canopy_fuel_grid_from_landfire( return Grid._from_model(expect(response, HTTPStatus.CREATED)) +# Named-method aliases for the per-band canopy reduction kwargs. Each maps a +# friendly string to the request model built with its default parameters; a +# caller who needs non-default parameters passes the model instance directly. +_CBD_METHODS = { + "load_over_depth": CanopyCbdLoadOverDepth, + "running_mean": CanopyCbdRunningMean, +} +_CBH_METHODS = { + "mean": CanopyCbhMean, + "minimum": CanopyCbhMinimum, + "percentile": CanopyCbhPercentile, + "threshold": CanopyProfileThreshold, +} +_CHM_METHODS = { + "percentile": CanopyChmHeightPercentile, + "threshold": CanopyProfileThreshold, +} +_CC_METHODS = { + "cover_fraction": CanopyCcCoverFraction, + "crown_overlap": CanopyCcCrownOverlap, + "crown_union": CanopyCcCrownUnion, +} + + +def _canopy_method(value, name: str, methods: dict): + """Resolve a per-band canopy method kwarg to its request model or UNSET. + + ``value`` may be ``None`` (UNSET), a string naming a default-parameter + method in ``methods``, or a prebuilt method model (passed through). + """ + if value is None: + return UNSET + if isinstance(value, str): + factory = methods.get(value) + if factory is None: + raise ValueError( + f"{name} must be one of {sorted(methods)} or a canopy " + f"{name} method object, got {value!r}." + ) + # CanopyCbhPercentile requires an explicit percentile; steer the + # caller to the model instead of constructing an invalid default. + try: + return factory() + except TypeError: + raise ValueError( + f'{name}="{value}" needs parameters; pass the method object, ' + f"e.g. {factory.__name__}(...)." + ) from None + return value + + +def _canopy_biomass_source(equations, column): + """Build the biomass source union from friendly kwargs, or UNSET.""" + if equations is not None and column is not None: + raise ValueError("Specify at most one of biomass_equations or biomass_column.") + if column is not None: + return InventoryColumnCanopyBiomassSource(column=column) + if equations is not None: + return AllometryCanopyBiomassSource(equations=CanopyBiomassEquations(equations)) + return UNSET + + +def _canopy_available_fuel(foliage_fraction, branchwood_fraction, size_partition): + """Build the available-fuel reduction from friendly kwargs, or UNSET.""" + if ( + foliage_fraction is None + and branchwood_fraction is None + and size_partition is None + ): + return UNSET + branchwood = UNSET + if branchwood_fraction is not None or size_partition is not None: + branchwood = CanopyBranchwood( + fraction=_opt(branchwood_fraction), + size_partition=( + CanopyBranchwoodSizePartition(size_partition) + if size_partition is not None + else UNSET + ), + ) + return CanopyAvailableFuel( + foliage_fraction=_opt(foliage_fraction), branchwood=branchwood + ) + + +def _canopy_max_crown_radius_source(equations, column): + """Build the max-crown-radius source union from friendly kwargs, or UNSET.""" + if equations is not None and column is not None: + raise ValueError( + "Specify at most one of max_crown_radius_equations or " + "max_crown_radius_column." + ) + if column is not None: + return InventoryColumnMaxCrownRadiusSource(column=column) + if equations is not None: + return CanopyAllometryMaxCrownRadiusSource( + equations=CanopyCrownWidthEquations(equations) + ) + return UNSET + + +def _canopy_crown_class_adjustment(value): + """Resolve the crown-class adjustment kwarg to its union model or UNSET.""" + if value is None: + return UNSET + if value == "none": + return CanopyNoCrownClassAdjustment() + if value == "fuelcalc_table": + return CanopyFuelcalcCrownClassAdjustment() + raise ValueError( + f'crown_class_adjustment must be "none" or "fuelcalc_table", ' f"got {value!r}." + ) + + +def create_canopy_fuel_grid_from_inventory( + inventory, + bands: Optional[list] = None, + biomass_equations: Optional[str] = None, + biomass_column: Optional[str] = None, + foliage_fraction: Optional[float] = None, + branchwood_fraction: Optional[float] = None, + branchwood_size_partition: Optional[str] = None, + species_inclusion: Optional[str] = None, + crown_class_adjustment: Optional[str] = None, + min_tree_height: Optional[float] = None, + vertical_distribution: Optional[str] = None, + layer_depth: Optional[float] = None, + horizontal_distribution: Optional[str] = None, + max_crown_radius_equations: Optional[str] = None, + max_crown_radius_column: Optional[str] = None, + cbd=None, + cbh=None, + chm=None, + cc=None, + output_resolution_m: Optional[float] = None, + align_to=None, + resampling: Optional[str] = None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a canopy fuel grid from a completed tree inventory. + + Derives the canopy metrics fire models consume — canopy bulk density + (``cbd``), canopy base height (``cbh``), canopy height (``chm``), canopy + cover (``cc``), and optionally canopy fuel load (``cfl``) — for each cell + directly from the inventory's trees. Only live trees contribute canopy + fuel. The bands share keys and units with the LANDFIRE canopy source. + + Parameters + ---------- + inventory : Inventory or str + A completed tree inventory (or its id) to derive canopy metrics from. + Passing the id alone requires the inventory to be reachable for its + domain; pass the Inventory object to avoid an extra lookup. + bands : list, optional + Output bands (``InventoryCanopyBand`` members or their string keys: + "cbd", "cbh", "chm", "cc", "cfl"). Defaults to the four canopy bands + ``cbd``, ``cbh``, ``chm``, and ``cc``. + biomass_equations : str, optional + Allometric equations for crown biomass: "nsvb" (default), "jenkins", + or "brown_1978". Mutually exclusive with ``biomass_column``. + biomass_column : str, optional + Inventory column holding precomputed per-tree available canopy fuel, + used in place of allometry. Mutually exclusive with + ``biomass_equations``; when set, ``available_fuel``, species + inclusion, and crown-class adjustment do not affect fuel magnitude. + foliage_fraction : float, optional + Fraction of foliage biomass counted as available fuel (allometry + only, default 1.0). + branchwood_fraction : float, optional + Fraction of the branchwood size basis counted as available fuel + (allometry only). + branchwood_size_partition : str, optional + Size basis for the branchwood fraction: "equations", + "brown_proportions", or "none" (allometry only). + species_inclusion : str, optional + Which species contribute: "all_species" or "fuelcalc_default" (which + excludes most hardwoods). + crown_class_adjustment : str, optional + Crown-weight adjustment for canopy position: "none" (default) or + "fuelcalc_table". + min_tree_height : float, optional + Trees shorter than this height in meters contribute no canopy fuel + (default 0.0). + vertical_distribution : str, optional + How each tree's fuel stacks over its crown: "reinhardt_2006" or + "uniform". + layer_depth : float, optional + Vertical profile layer depth in meters (default 0.3048). + horizontal_distribution : str, optional + How a tree's fuel is attributed to cells: "crown_projected" (default) + or "stem". + max_crown_radius_equations : str, optional + Allometric equations for maximum crown radius: "purves" (default) or + "crookston_stage". Mutually exclusive with + ``max_crown_radius_column``. + max_crown_radius_column : str, optional + Inventory column holding per-tree maximum crown radius in meters, used + in place of allometry. Mutually exclusive with + ``max_crown_radius_equations``. + cbd : str or CanopyCbdLoadOverDepth or CanopyCbdRunningMean, optional + Canopy bulk density method: "load_over_depth", "running_mean", or a + method object for non-default parameters. + cbh : str or method object, optional + Canopy base height method: "mean", "minimum", "percentile", + "threshold", or a method object (``CanopyCbhMean``, + ``CanopyCbhMinimum``, ``CanopyCbhPercentile``, or + ``CanopyProfileThreshold``) for non-default parameters. + chm : str or method object, optional + Canopy height method: "percentile", "threshold", or a method object + (``CanopyChmHeightPercentile`` or ``CanopyProfileThreshold``). + cc : str or method object, optional + Canopy cover method: "cover_fraction", "crown_overlap", "crown_union", + or a method object for non-default parameters. + output_resolution_m : float, optional + Output cell size in meters, anchored to the domain origin. Defaults to + 30 m when no alignment target is given. + align_to : Grid or str, optional + Match the lattice of an existing grid (or its id). + resampling : str, optional + Resampling method for the alignment target. + name, description : str, optional + Metadata for the grid. + tags : List[str], optional + Tags for the grid. + + Returns + ------- + Grid + The created Grid object (job status "pending" or "running"). + + Raises + ------ + ValueError + If ``inventory`` is passed as a bare id with no resolvable domain, if + mutually exclusive kwargs are combined, or if a method name is + unknown. + + Examples + -------- + >>> import fastfuels_sdk.v2 as ff + >>> grid = ff.grids.create_canopy_fuel_grid_from_inventory( + ... inventory, bands=["cbd", "cbh", "chm", "cc", "cfl"] + ... ) + >>> grid.wait() + """ + domain_id = getattr(inventory, "domain_id", None) + if domain_id is None: + raise ValueError( + "Pass an Inventory object so its domain can be resolved; a bare " + "inventory id does not identify a domain." + ) + source_inventory_id = getattr(inventory, "id", inventory) + + request_body = CreateInventoryCanopyRequest( + source_inventory_id=source_inventory_id, + biomass_source=_canopy_biomass_source(biomass_equations, biomass_column), + available_fuel=_canopy_available_fuel( + foliage_fraction, branchwood_fraction, branchwood_size_partition + ), + species_inclusion=( + CanopySpeciesInclusion(species_inclusion) + if species_inclusion is not None + else UNSET + ), + crown_class_adjustment=_canopy_crown_class_adjustment(crown_class_adjustment), + min_tree_height=_opt(min_tree_height), + vertical_distribution=( + CanopyVerticalDistribution(vertical_distribution) + if vertical_distribution is not None + else UNSET + ), + layer_depth=_opt(layer_depth), + horizontal_distribution=( + CanopyHorizontalDistribution(horizontal_distribution) + if horizontal_distribution is not None + else UNSET + ), + max_crown_radius_source=_canopy_max_crown_radius_source( + max_crown_radius_equations, max_crown_radius_column + ), + cbd=_canopy_method(cbd, "cbd", _CBD_METHODS), + cbh=_canopy_method(cbh, "cbh", _CBH_METHODS), + chm=_canopy_method(chm, "chm", _CHM_METHODS), + cc=_canopy_method(cc, "cc", _CC_METHODS), + bands=_enum_list(bands, InventoryCanopyBand), + alignment=_build_alignment( + output_resolution_m, align_to, resampling=resampling + ), + name=name, + description=description, + tags=_opt(tags), + ) + response = create_inventory_canopy_grid.sync_detailed( + domain_id, client=ensure_client(), body=request_body + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + def create_canopy_height_grid_from_meta( domain, version: Optional[str] = None, diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 7b1e01e..a64460c 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -22,6 +22,7 @@ _fill_for, _opt, check_3dep_coverage, + create_canopy_fuel_grid_from_inventory, create_canopy_fuel_grid_from_landfire, create_canopy_height_grid_from_meta, create_canopy_height_grid_from_naip_chm, @@ -46,8 +47,11 @@ from fastfuels_sdk.v2.client_library.models import ( Band, BandType, + CanopyCbhPercentile, ContinuousBandSummary, DuetBand, + InventoryCanopyBand, + InventoryColumnCanopyBiomassSource, FccsLookupBand, Fbfm13LookupBand, GridAlignmentDomainTarget, @@ -492,6 +496,172 @@ def test_create_live(self, completed_tree_inventory): voxels.delete() +class TestCreateCanopyFuelGridFromInventory: + @staticmethod + def _inventory(status=JobStatus.COMPLETED): + return SimpleNamespace(id="inv-id", domain_id="domain-id", status=status) + + def _patch_create(self, monkeypatch): + """Patch the endpoint to capture the request body and return a Grid.""" + created = Grid( + id="canopy-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(domain_id=domain_id, client=client, body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + client = object() + monkeypatch.setattr(grids, "ensure_client", lambda: client) + monkeypatch.setattr( + grids.create_inventory_canopy_grid, "sync_detailed", fake_create + ) + return captured + + def test_builds_request_from_inventory_object(self, monkeypatch): + captured = self._patch_create(monkeypatch) + + grid = create_canopy_fuel_grid_from_inventory( + self._inventory(), + bands=["cbd", InventoryCanopyBand.CFL], + biomass_equations="nsvb", + species_inclusion="fuelcalc_default", + crown_class_adjustment="fuelcalc_table", + min_tree_height=1.83, + vertical_distribution="uniform", + horizontal_distribution="stem", + max_crown_radius_equations="crookston_stage", + cbd="running_mean", + cbh="minimum", + chm="threshold", + cc="crown_union", + output_resolution_m=30, + name="canopy", + tags=["test"], + ) + + assert grid.id == "canopy-grid-id" + assert captured["domain_id"] == "domain-id" + body = captured["body"] + assert body.source_inventory_id == "inv-id" + assert body.bands == [InventoryCanopyBand.CBD, InventoryCanopyBand.CFL] + assert body.biomass_source.equations.value == "nsvb" + assert body.species_inclusion.value == "fuelcalc_default" + assert body.crown_class_adjustment.method == "fuelcalc_table" + assert body.min_tree_height == 1.83 + assert body.vertical_distribution.value == "uniform" + assert body.horizontal_distribution.value == "stem" + assert body.max_crown_radius_source.equations.value == "crookston_stage" + assert body.cbd.method == "maximum_running_mean" + assert body.cbh.method == "minimum" + assert body.chm.method == "bulk_density_threshold" + assert body.cc.method == "crown_union" + assert body.alignment.resolution == 30 + assert body.name == "canopy" + assert body.tags == ["test"] + + def test_inventory_column_biomass_source(self, monkeypatch): + captured = self._patch_create(monkeypatch) + + create_canopy_fuel_grid_from_inventory( + self._inventory(), biomass_column="available_canopy_fuel" + ) + + body = captured["body"] + assert isinstance(body.biomass_source, InventoryColumnCanopyBiomassSource) + assert body.biomass_source.column == "available_canopy_fuel" + + def test_available_fuel_from_kwargs(self, monkeypatch): + captured = self._patch_create(monkeypatch) + + create_canopy_fuel_grid_from_inventory( + self._inventory(), + foliage_fraction=0.9, + branchwood_fraction=0.5, + branchwood_size_partition="equations", + ) + + available_fuel = captured["body"].available_fuel + assert available_fuel.foliage_fraction == 0.9 + assert available_fuel.branchwood.fraction == 0.5 + assert available_fuel.branchwood.size_partition.value == "equations" + + def test_method_objects_passed_through(self, monkeypatch): + captured = self._patch_create(monkeypatch) + + percentile = CanopyCbhPercentile(percentile=25) + create_canopy_fuel_grid_from_inventory(self._inventory(), cbh=percentile) + + assert captured["body"].cbh is percentile + + def test_defaults_are_unset(self, monkeypatch): + captured = self._patch_create(monkeypatch) + + create_canopy_fuel_grid_from_inventory(self._inventory()) + + body = captured["body"] + assert body.biomass_source is UNSET + assert body.available_fuel is UNSET + assert body.cbd is UNSET + assert body.bands is UNSET + assert body.alignment is UNSET + + def test_bare_id_without_domain_raises(self): + with pytest.raises(ValueError, match="Inventory object"): + create_canopy_fuel_grid_from_inventory("inv-id") + + @pytest.mark.parametrize( + "kwargs", + [ + {"biomass_equations": "nsvb", "biomass_column": "col"}, + { + "max_crown_radius_equations": "purves", + "max_crown_radius_column": "col", + }, + {"crown_class_adjustment": "bogus"}, + {"cbh": "bogus_method"}, + {"cbh": "percentile"}, + ], + ) + def test_invalid_kwargs_raise(self, kwargs): + with pytest.raises(ValueError): + create_canopy_fuel_grid_from_inventory(self._inventory(), **kwargs) + + def test_create_live(self, completed_tree_inventory): + canopy = create_canopy_fuel_grid_from_inventory( + completed_tree_inventory, + bands=["cbd", "cbh", "chm", "cc", "cfl"], + output_resolution_m=30, + name="test_inventory_canopy", + tags=["test"], + ) + try: + canopy.wait() + assert canopy.status == JobStatus.COMPLETED + assert {band.key for band in canopy.bands} == { + "cbd", + "cbh", + "chm", + "cc", + "cfl", + } + cbd = canopy.to_numpy("cbd") + assert cbd.ndim == 2 + assert np.isfinite(cbd).any() + finally: + canopy.delete() + + class TestCreateFuelModelGridFromLandfireFbfm40: def test_create(self, test_domain): # remove_non_burnable exercises the string -> enum list coercion