From ec4f89e6c0d4b3d85534d4b18a5fec26051279a6 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 10 Dec 2025 11:00:27 -0700 Subject: [PATCH] Add tree inventory modifications and treatments in convenience module (#39) --- docs/tutorials/export_to_quicfire.md | 40 ++- fastfuels_sdk/convenience.py | 82 +++++- tests/test_convenience_config.py | 376 ++++++++++++++++++++++++++- 3 files changed, 494 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/export_to_quicfire.md b/docs/tutorials/export_to_quicfire.md index e61aaba..0689c8d 100644 --- a/docs/tutorials/export_to_quicfire.md +++ b/docs/tutorials/export_to_quicfire.md @@ -397,10 +397,48 @@ features_config = { ```python tree_inventory_config = { - "featureMasks": ["road", "water"] # Features to mask from inventory + "version": "2022", # TreeMap version: "2014", "2016", "2020", "2022" + "seed": 42, # Random seed for reproducibility (optional) + "featureMasks": ["road", "water"], # Features to mask from inventory + "canopyHeightMapSource": "Meta2024", # High-resolution canopy height data + + # Tree attribute modifications (optional) + "modifications": [ + { + "conditions": [ + {"attribute": "CR", "operator": "gt", "value": 0.1} + ], + "actions": [ + {"attribute": "CR", "modifier": "multiply", "value": 0.9} + ] + } + ], + + # Silvicultural treatments (optional) + "treatments": [ + { + "method": "proportionalThinning", + "targetMetric": "basalArea", + "targetValue": 25.0 + } + ] } ``` +**Available modification attributes:** +- `HT`: Height (meters) +- `DIA`: Diameter at breast height (centimeters) +- `CR`: Crown ratio (0-1) - crown length = height × crown ratio +- `SPCD`: Species code (integer) + +**Available operators:** `eq`, `ne`, `gt`, `lt`, `ge`, `le` + +**Available modifiers:** `multiply`, `divide`, `add`, `subtract`, `replace`, `remove` + +**Treatment methods:** +- `proportionalThinning`: Thin to target basal area (m²/ha) +- `directionalThinning`: Remove trees above/below size threshold + #### Advanced Configuration Examples **Completely custom surface grid:** diff --git a/fastfuels_sdk/convenience.py b/fastfuels_sdk/convenience.py index 07ce0d2..70f4c66 100644 --- a/fastfuels_sdk/convenience.py +++ b/fastfuels_sdk/convenience.py @@ -103,6 +103,7 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None: } DEFAULT_TREE_INVENTORY_CONFIG = { + "version": "2022", "featureMasks": ["road", "water"], "canopyHeightMapSource": "Meta2024", } @@ -366,7 +367,23 @@ def export_roi( Structure:: { - "featureMasks": ["road", "water"] # Features to mask out + "featureMasks": ["road", "water"], # Features to mask out + "canopyHeightMapSource": "Meta2024", # High-resolution canopy height map + "version": "2022", # TreeMap version + "seed": 42, # Random seed for reproducibility + "modifications": [ # Tree attribute modifications + { + "conditions": [{"attribute": "CR", "operator": "gt", "value": 0.1}], + "actions": [{"attribute": "CR", "modifier": "multiply", "value": 0.9}] + } + ], + "treatments": [ # Silvicultural treatments + { + "method": "proportionalThinning", + "targetMetric": "basalArea", + "targetValue": 25.0 + } + ] } Returns @@ -415,6 +432,43 @@ def export_roi( ... features_config=features_config ... ) + Apply tree inventory modifications to reduce crown ratio: + + >>> tree_inventory_config = { + ... "modifications": [ + ... { + ... "conditions": [{"attribute": "CR", "operator": "gt", "value": 0.1}], + ... "actions": [{"attribute": "CR", "modifier": "multiply", "value": 0.9}] + ... } + ... ] + ... } + >>> export = export_roi( + ... roi, "modified_trees.zip", + ... tree_inventory_config=tree_inventory_config + ... ) + + Apply silvicultural treatments and remove small trees: + + >>> tree_inventory_config = { + ... "modifications": [ + ... { + ... "conditions": [{"attribute": "DIA", "operator": "lt", "value": 10}], + ... "actions": [{"attribute": "all", "modifier": "remove"}] + ... } + ... ], + ... "treatments": [ + ... { + ... "method": "proportionalThinning", + ... "targetMetric": "basalArea", + ... "targetValue": 25.0 + ... } + ... ] + ... } + >>> export = export_roi( + ... roi, "thinned_forest.zip", + ... tree_inventory_config=tree_inventory_config + ... ) + Notes ----- This function performs the complete export workflow: @@ -431,6 +485,26 @@ def export_roi( All configuration parameters support partial overrides - only specify the values you want to change from the defaults. The function will merge your configuration with sensible defaults for all other parameters. + + **Tree Inventory Modifications and Treatments:** + + When both modifications and treatments are specified in tree_inventory_config, + they are applied in the following order: + + 1. **Modifications** are applied first to adjust or remove individual trees + based on their attributes (height, diameter, crown ratio, species) + 2. **Treatments** are then applied to the modified inventory to achieve + target stand-level metrics (basal area, density) + + Use modifications when you want to: + - Adjust specific tree attributes (e.g., multiply heights by 0.9) + - Remove trees matching certain criteria (e.g., diameter < 10 cm) + - Apply expression-based conditions (e.g., crown length < 1 m) + + Use treatments when you want to: + - Apply silvicultural operations (proportional or directional thinning) + - Achieve target stand metrics (e.g., basal area of 25 m²/ha) + - Simulate forest management scenarios """ # Validate export format supported_formats = ["QUIC-Fire", "zarr"] @@ -500,10 +574,14 @@ def export_roi( tree_inventory = Inventories.from_domain_id( domain.id ).create_tree_inventory_from_treemap( - feature_masks=merged_tree_inventory_config.get("featureMasks", []), + version=merged_tree_inventory_config.get("version", "2022"), + seed=merged_tree_inventory_config.get("seed"), canopy_height_map_source=merged_tree_inventory_config.get( "canopyHeightMapSource" ), + modifications=merged_tree_inventory_config.get("modifications"), + treatments=merged_tree_inventory_config.get("treatments"), + feature_masks=merged_tree_inventory_config.get("featureMasks", []), ) # Create surface grid using configuration diff --git a/tests/test_convenience_config.py b/tests/test_convenience_config.py index 516e6ee..db4b26a 100644 --- a/tests/test_convenience_config.py +++ b/tests/test_convenience_config.py @@ -150,7 +150,12 @@ def test_current_hardcoded_topography_config( # Verify tree inventory was created with current masks mock_inventories_instance.create_tree_inventory_from_treemap.assert_called_once_with( - feature_masks=["road", "water"], canopy_height_map_source="Meta2024" + version="2022", + seed=None, + canopy_height_map_source="Meta2024", + modifications=None, + treatments=None, + feature_masks=["road", "water"], ) def test_default_configuration_constants(self): @@ -924,3 +929,372 @@ def test_export_roi_to_quicfire_is_wrapper(self, mock_export_roi, sample_roi): tree_inventory_config=None, ) assert result == mock_export + + +class TestTreeInventoryModificationsAndTreatments: + """Test that modifications and treatments are properly passed through to tree inventory.""" + + @patch("fastfuels_sdk.convenience.Domain") + @patch("fastfuels_sdk.convenience.Features") + @patch("fastfuels_sdk.convenience.TopographyGridBuilder") + @patch("fastfuels_sdk.convenience.SurfaceGridBuilder") + @patch("fastfuels_sdk.convenience.TreeGridBuilder") + @patch("fastfuels_sdk.convenience.Grids") + @patch("fastfuels_sdk.convenience.Inventories") + def test_modifications_passed_to_tree_inventory( + self, + mock_inventories, + mock_grids, + mock_tree_builder, + mock_surface_builder, + mock_topo_builder, + mock_features, + mock_domain, + sample_roi, + ): + """Test that modifications config is passed to create_tree_inventory_from_treemap.""" + # Setup basic mocks + mock_domain.from_geodataframe.return_value.id = "test-domain" + mock_features_instance = Mock() + mock_features.from_domain_id.return_value = mock_features_instance + + # Mock builders + mock_topo_instance = Mock() + mock_topo_builder.return_value = mock_topo_instance + mock_topo_instance.with_elevation_from_3dep.return_value = mock_topo_instance + mock_topo_instance.build.return_value = Mock() + + mock_surface_instance = Mock() + mock_surface_builder.return_value = mock_surface_instance + mock_surface_instance.with_fuel_load_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_fuel_depth_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_uniform_fuel_moisture.return_value = ( + mock_surface_instance + ) + mock_surface_instance.build.return_value = Mock() + + mock_tree_instance = Mock() + mock_tree_builder.return_value = mock_tree_instance + mock_tree_instance.with_bulk_density_from_tree_inventory.return_value = ( + mock_tree_instance + ) + mock_tree_instance.with_uniform_fuel_moisture.return_value = mock_tree_instance + mock_tree_instance.build.return_value = Mock() + + mock_grids_instance = Mock() + mock_grids.from_domain_id.return_value = mock_grids_instance + mock_export = Mock() + mock_export.status = "completed" + mock_grids_instance.create_export.return_value = mock_export + mock_grids_instance.create_feature_grid.return_value = Mock() + + # Setup inventories mock + mock_inventories_instance = Mock() + mock_inventories.from_domain_id.return_value = mock_inventories_instance + mock_tree_inventory = Mock() + mock_inventories_instance.create_tree_inventory_from_treemap.return_value = ( + mock_tree_inventory + ) + + # Define modifications configuration + modifications_config = [ + { + "conditions": [{"attribute": "CR", "operator": "gt", "value": 0.1}], + "actions": [{"attribute": "CR", "modifier": "multiply", "value": 0.9}], + } + ] + + tree_inventory_config = {"modifications": modifications_config} + + # Call export_roi with modifications + result = export_roi_to_quicfire( + sample_roi, "/tmp/test", tree_inventory_config=tree_inventory_config + ) + + # Verify create_tree_inventory_from_treemap was called with modifications + mock_inventories_instance.create_tree_inventory_from_treemap.assert_called_once() + call_kwargs = ( + mock_inventories_instance.create_tree_inventory_from_treemap.call_args[1] + ) + + assert "modifications" in call_kwargs + assert call_kwargs["modifications"] == modifications_config + assert result == mock_export + + @patch("fastfuels_sdk.convenience.Domain") + @patch("fastfuels_sdk.convenience.Features") + @patch("fastfuels_sdk.convenience.TopographyGridBuilder") + @patch("fastfuels_sdk.convenience.SurfaceGridBuilder") + @patch("fastfuels_sdk.convenience.TreeGridBuilder") + @patch("fastfuels_sdk.convenience.Grids") + @patch("fastfuels_sdk.convenience.Inventories") + def test_treatments_passed_to_tree_inventory( + self, + mock_inventories, + mock_grids, + mock_tree_builder, + mock_surface_builder, + mock_topo_builder, + mock_features, + mock_domain, + sample_roi, + ): + """Test that treatments config is passed to create_tree_inventory_from_treemap.""" + # Setup basic mocks (same as above) + mock_domain.from_geodataframe.return_value.id = "test-domain" + mock_features_instance = Mock() + mock_features.from_domain_id.return_value = mock_features_instance + + mock_topo_instance = Mock() + mock_topo_builder.return_value = mock_topo_instance + mock_topo_instance.with_elevation_from_3dep.return_value = mock_topo_instance + mock_topo_instance.build.return_value = Mock() + + mock_surface_instance = Mock() + mock_surface_builder.return_value = mock_surface_instance + mock_surface_instance.with_fuel_load_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_fuel_depth_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_uniform_fuel_moisture.return_value = ( + mock_surface_instance + ) + mock_surface_instance.build.return_value = Mock() + + mock_tree_instance = Mock() + mock_tree_builder.return_value = mock_tree_instance + mock_tree_instance.with_bulk_density_from_tree_inventory.return_value = ( + mock_tree_instance + ) + mock_tree_instance.with_uniform_fuel_moisture.return_value = mock_tree_instance + mock_tree_instance.build.return_value = Mock() + + mock_grids_instance = Mock() + mock_grids.from_domain_id.return_value = mock_grids_instance + mock_export = Mock() + mock_export.status = "completed" + mock_grids_instance.create_export.return_value = mock_export + mock_grids_instance.create_feature_grid.return_value = Mock() + + mock_inventories_instance = Mock() + mock_inventories.from_domain_id.return_value = mock_inventories_instance + mock_tree_inventory = Mock() + mock_inventories_instance.create_tree_inventory_from_treemap.return_value = ( + mock_tree_inventory + ) + + # Define treatments configuration + treatments_config = [ + { + "method": "proportionalThinning", + "targetMetric": "basalArea", + "targetValue": 25.0, + } + ] + + tree_inventory_config = {"treatments": treatments_config} + + # Call export_roi with treatments + result = export_roi_to_quicfire( + sample_roi, "/tmp/test", tree_inventory_config=tree_inventory_config + ) + + # Verify create_tree_inventory_from_treemap was called with treatments + mock_inventories_instance.create_tree_inventory_from_treemap.assert_called_once() + call_kwargs = ( + mock_inventories_instance.create_tree_inventory_from_treemap.call_args[1] + ) + + assert "treatments" in call_kwargs + assert call_kwargs["treatments"] == treatments_config + assert result == mock_export + + @patch("fastfuels_sdk.convenience.Domain") + @patch("fastfuels_sdk.convenience.Features") + @patch("fastfuels_sdk.convenience.TopographyGridBuilder") + @patch("fastfuels_sdk.convenience.SurfaceGridBuilder") + @patch("fastfuels_sdk.convenience.TreeGridBuilder") + @patch("fastfuels_sdk.convenience.Grids") + @patch("fastfuels_sdk.convenience.Inventories") + def test_modifications_and_treatments_together( + self, + mock_inventories, + mock_grids, + mock_tree_builder, + mock_surface_builder, + mock_topo_builder, + mock_features, + mock_domain, + sample_roi, + ): + """Test that both modifications and treatments can be used together.""" + # Setup basic mocks + mock_domain.from_geodataframe.return_value.id = "test-domain" + mock_features_instance = Mock() + mock_features.from_domain_id.return_value = mock_features_instance + + mock_topo_instance = Mock() + mock_topo_builder.return_value = mock_topo_instance + mock_topo_instance.with_elevation_from_3dep.return_value = mock_topo_instance + mock_topo_instance.build.return_value = Mock() + + mock_surface_instance = Mock() + mock_surface_builder.return_value = mock_surface_instance + mock_surface_instance.with_fuel_load_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_fuel_depth_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_uniform_fuel_moisture.return_value = ( + mock_surface_instance + ) + mock_surface_instance.build.return_value = Mock() + + mock_tree_instance = Mock() + mock_tree_builder.return_value = mock_tree_instance + mock_tree_instance.with_bulk_density_from_tree_inventory.return_value = ( + mock_tree_instance + ) + mock_tree_instance.with_uniform_fuel_moisture.return_value = mock_tree_instance + mock_tree_instance.build.return_value = Mock() + + mock_grids_instance = Mock() + mock_grids.from_domain_id.return_value = mock_grids_instance + mock_export = Mock() + mock_export.status = "completed" + mock_grids_instance.create_export.return_value = mock_export + mock_grids_instance.create_feature_grid.return_value = Mock() + + mock_inventories_instance = Mock() + mock_inventories.from_domain_id.return_value = mock_inventories_instance + mock_tree_inventory = Mock() + mock_inventories_instance.create_tree_inventory_from_treemap.return_value = ( + mock_tree_inventory + ) + + # Define both modifications and treatments + modifications_config = [ + { + "conditions": [{"attribute": "DIA", "operator": "lt", "value": 10}], + "actions": [{"modifier": "remove"}], + } + ] + + treatments_config = [ + { + "method": "proportionalThinning", + "targetMetric": "basalArea", + "targetValue": 25.0, + } + ] + + tree_inventory_config = { + "modifications": modifications_config, + "treatments": treatments_config, + "seed": 42, + } + + # Call export_roi with both modifications and treatments + result = export_roi_to_quicfire( + sample_roi, "/tmp/test", tree_inventory_config=tree_inventory_config + ) + + # Verify create_tree_inventory_from_treemap was called with both + mock_inventories_instance.create_tree_inventory_from_treemap.assert_called_once() + call_kwargs = ( + mock_inventories_instance.create_tree_inventory_from_treemap.call_args[1] + ) + + assert "modifications" in call_kwargs + assert call_kwargs["modifications"] == modifications_config + assert "treatments" in call_kwargs + assert call_kwargs["treatments"] == treatments_config + assert "seed" in call_kwargs + assert call_kwargs["seed"] == 42 + assert result == mock_export + + @patch("fastfuels_sdk.convenience.Domain") + @patch("fastfuels_sdk.convenience.Features") + @patch("fastfuels_sdk.convenience.TopographyGridBuilder") + @patch("fastfuels_sdk.convenience.SurfaceGridBuilder") + @patch("fastfuels_sdk.convenience.TreeGridBuilder") + @patch("fastfuels_sdk.convenience.Grids") + @patch("fastfuels_sdk.convenience.Inventories") + def test_no_modifications_or_treatments_by_default( + self, + mock_inventories, + mock_grids, + mock_tree_builder, + mock_surface_builder, + mock_topo_builder, + mock_features, + mock_domain, + sample_roi, + ): + """Test that modifications and treatments are None by default.""" + # Setup basic mocks + mock_domain.from_geodataframe.return_value.id = "test-domain" + mock_features_instance = Mock() + mock_features.from_domain_id.return_value = mock_features_instance + + mock_topo_instance = Mock() + mock_topo_builder.return_value = mock_topo_instance + mock_topo_instance.with_elevation_from_3dep.return_value = mock_topo_instance + mock_topo_instance.build.return_value = Mock() + + mock_surface_instance = Mock() + mock_surface_builder.return_value = mock_surface_instance + mock_surface_instance.with_fuel_load_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_fuel_depth_from_landfire.return_value = ( + mock_surface_instance + ) + mock_surface_instance.with_uniform_fuel_moisture.return_value = ( + mock_surface_instance + ) + mock_surface_instance.build.return_value = Mock() + + mock_tree_instance = Mock() + mock_tree_builder.return_value = mock_tree_instance + mock_tree_instance.with_bulk_density_from_tree_inventory.return_value = ( + mock_tree_instance + ) + mock_tree_instance.with_uniform_fuel_moisture.return_value = mock_tree_instance + mock_tree_instance.build.return_value = Mock() + + mock_grids_instance = Mock() + mock_grids.from_domain_id.return_value = mock_grids_instance + mock_export = Mock() + mock_export.status = "completed" + mock_grids_instance.create_export.return_value = mock_export + mock_grids_instance.create_feature_grid.return_value = Mock() + + mock_inventories_instance = Mock() + mock_inventories.from_domain_id.return_value = mock_inventories_instance + mock_tree_inventory = Mock() + mock_inventories_instance.create_tree_inventory_from_treemap.return_value = ( + mock_tree_inventory + ) + + # Call export_roi without tree_inventory_config + result = export_roi_to_quicfire(sample_roi, "/tmp/test") + + # Verify create_tree_inventory_from_treemap was called with None for modifications and treatments + mock_inventories_instance.create_tree_inventory_from_treemap.assert_called_once() + call_kwargs = ( + mock_inventories_instance.create_tree_inventory_from_treemap.call_args[1] + ) + + assert "modifications" in call_kwargs + assert call_kwargs["modifications"] is None + assert "treatments" in call_kwargs + assert call_kwargs["treatments"] is None + assert result == mock_export