From 1c44e1128026518eaa5b28e49cf7d08876e6118a Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 14 Jan 2026 10:34:16 -0700 Subject: [PATCH] Add spatial modification support to SurfaceGridBuilder with tests and documentation --- docs/guides/grids.md | 68 +++++ fastfuels_sdk/grids/surface_grid_builder.py | 124 +++++++++ tests/grids/test_surface_grid_builder.py | 294 ++++++++++++++++++++ 3 files changed, 486 insertions(+) diff --git a/docs/guides/grids.md b/docs/guides/grids.md index 7db5ae8..0e8bf37 100644 --- a/docs/guides/grids.md +++ b/docs/guides/grids.md @@ -141,6 +141,74 @@ surface_grid = (SurfaceGridBuilder("abc123") .build()) ``` +#### Spatial Modifications + +You can apply modifications to specific geographic areas using spatial conditions. This is useful for implementing fuel treatments, creating fuel breaks, or modifying fuels in specific zones: + +```python +from fastfuels_sdk import SurfaceGridBuilder + +# Define a fuel break polygon (GeoJSON format) +fuel_break = { + "type": "Polygon", + "coordinates": [[ + [-120.5, 39.0], + [-120.5, 39.1], + [-120.4, 39.1], + [-120.4, 39.0], + [-120.5, 39.0] + ]] +} + +# Create a surface grid with a fuel break that reduces fuel load by 50% +surface_grid = (SurfaceGridBuilder("abc123") + .with_fuel_load_from_landfire(product="FBFM40") + .with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=fuel_break, + operator="within" + ) + .build()) +``` + +The spatial modification method supports: +- **Operators**: `"within"` (inside geometry), `"outside"` (outside geometry), `"intersects"` (overlaps geometry) +- **Targets**: `"centroid"` (test cell center point) or `"cell"` (test entire cell bounds) +- **Custom CRS**: Specify a coordinate reference system for non-WGS84 geometries +- **Combined conditions**: Add attribute-based conditions alongside spatial conditions + +```python +# Example: Replace FBFM to non-burnable outside a study area +study_area = { + "type": "Polygon", + "coordinates": [[ + [-120.5, 39.0], [-120.5, 39.5], + [-120.0, 39.5], [-120.0, 39.0], + [-120.5, 39.0] + ]] +} + +surface_grid = (SurfaceGridBuilder("abc123") + .with_fbfm_from_landfire(product="FBFM40") + .with_spatial_modification( + actions={"attribute": "FBFM", "modifier": "replace", "value": "NB1"}, + geometry=study_area, + operator="outside" + ) + .build()) + +# Example: Apply treatment only to grass fuels within a specific area +surface_grid = (SurfaceGridBuilder("abc123") + .with_fuel_load_from_landfire(product="FBFM40") + .with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.3}, + geometry=fuel_break, + operator="within", + additional_conditions={"attribute": "FBFM", "operator": "eq", "value": "GR2"} + ) + .build()) +``` + ### Topography Grid Builder The TopographyGridBuilder helps create topography grids: diff --git a/fastfuels_sdk/grids/surface_grid_builder.py b/fastfuels_sdk/grids/surface_grid_builder.py index 45a4849..6100c49 100644 --- a/fastfuels_sdk/grids/surface_grid_builder.py +++ b/fastfuels_sdk/grids/surface_grid_builder.py @@ -633,6 +633,130 @@ def with_modification( ) return self + def with_spatial_modification( + self, + actions: ( + dict + | list[dict] + | SurfaceGridModificationAction + | list[SurfaceGridModificationAction] + ), + geometry: dict, + operator: str = "within", + target: str = None, + crs: dict = None, + additional_conditions: ( + dict + | list[dict] + | SurfaceGridModificationCondition + | list[SurfaceGridModificationCondition] + ) = None, + ) -> "SurfaceGridBuilder": + """Add a spatial modification to the surface grid. + + Spatial modifications allow you to modify surface grid attributes based on + geographic areas defined by GeoJSON geometries. This is useful for applying + treatments to specific areas, such as reducing fuel load within a fuel break + polygon or increasing moisture in riparian zones. + + Parameters + ---------- + actions : dict or list[dict] or SurfaceGridModificationAction or list[SurfaceGridModificationAction] + Action(s) to apply to cells matching the spatial condition. + geometry : dict + GeoJSON geometry (Polygon or MultiPolygon) defining the spatial area. + Must include "type" and "coordinates" keys. + operator : str, optional + Spatial relationship to test. Options are: + - "within": Select cells inside the geometry (default) + - "outside": Select cells outside the geometry + - "intersects": Select cells that overlap the geometry + target : str, optional + Which part of the cell to test against the geometry: + - "centroid": Test the cell's center point (default) + - "cell": Test the entire cell bounds + crs : dict, optional + Coordinate reference system for the geometry. If not specified, + WGS84 (EPSG:4326) is assumed. Format: {"type": "name", "properties": {"name": "EPSG:XXXX"}} + additional_conditions : dict or list[dict] or SurfaceGridModificationCondition or list[SurfaceGridModificationCondition], optional + Additional attribute-based conditions to combine with the spatial condition. + The modification will only apply to cells matching both the spatial and + attribute conditions. + + Returns + ------- + SurfaceGridBuilder + Returns self for method chaining. + + Examples + -------- + Reduce fuel load by 50% within a fuel break polygon: + + >>> builder = SurfaceGridBuilder("abc123") + >>> fuel_break = { + ... "type": "Polygon", + ... "coordinates": [[[-120.5, 39.0], [-120.5, 39.1], [-120.4, 39.1], [-120.4, 39.0], [-120.5, 39.0]]] + ... } + >>> builder.with_spatial_modification( + ... actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + ... geometry=fuel_break, + ... operator="within" + ... ) + + Replace FBFM to non-burnable outside a study area: + + >>> study_area = { + ... "type": "Polygon", + ... "coordinates": [[[-120.5, 39.0], [-120.5, 39.5], [-120.0, 39.5], [-120.0, 39.0], [-120.5, 39.0]]] + ... } + >>> builder.with_spatial_modification( + ... actions={"attribute": "FBFM", "modifier": "replace", "value": "NB1"}, + ... geometry=study_area, + ... operator="outside" + ... ) + + Increase fuel moisture in specific areas with UTM coordinates: + + >>> riparian_zone = { + ... "type": "Polygon", + ... "coordinates": [[[500000, 4300000], [500000, 4301000], [501000, 4301000], [501000, 4300000], [500000, 4300000]]] + ... } + >>> builder.with_spatial_modification( + ... actions={"attribute": "fuelMoisture", "modifier": "multiply", "value": 1.5}, + ... geometry=riparian_zone, + ... operator="within", + ... crs={"type": "name", "properties": {"name": "EPSG:32610"}} + ... ) + + Combine spatial and attribute conditions: + + >>> builder.with_spatial_modification( + ... actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.3}, + ... geometry=fuel_break, + ... operator="within", + ... additional_conditions={"attribute": "FBFM", "operator": "eq", "value": "GR2"} + ... ) # Only reduces fuel load for GR2 cells within the fuel break + """ + # Build the spatial condition + spatial_condition = { + "operator": operator, + "geometry": geometry, + } + if target is not None: + spatial_condition["target"] = target + if crs is not None: + spatial_condition["crs"] = crs + + # Combine spatial condition with any additional attribute conditions + conditions = [spatial_condition] + if additional_conditions is not None: + if isinstance(additional_conditions, list): + conditions.extend(additional_conditions) + else: + conditions.append(additional_conditions) + + return self.with_modification(actions=actions, conditions=conditions) + def build(self) -> "SurfaceGrid": """Create the surface grid with configured attributes. diff --git a/tests/grids/test_surface_grid_builder.py b/tests/grids/test_surface_grid_builder.py index 786a01d..5ca2d7e 100644 --- a/tests/grids/test_surface_grid_builder.py +++ b/tests/grids/test_surface_grid_builder.py @@ -464,6 +464,300 @@ def test_modification_without_conditions(self, builder): assert len(mod["conditions"]) == 0 +class TestSpatialModifications: + """Test suite for spatial grid modifications using UTM coordinates.""" + + @pytest.fixture(scope="class") + def spatial_domain(self): + """Create a small (~200m x 200m) UTM domain for spatial modification tests.""" + from fastfuels_sdk.domains import Domain + + # Small 200m x 200m domain in UTM Zone 11N (near Tahoe area) + geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [255900, 4324800], + [255900, 4325000], + [256100, 4325000], + [256100, 4324800], + [255900, 4324800], + ] + ], + }, + } + ], + "crs": {"type": "name", "properties": {"name": "EPSG:32611"}}, + } + + domain = Domain.from_geojson( + geojson, + name="spatial_mod_test_domain", + description="Small UTM domain for spatial modification testing", + horizontal_resolution=1.0, + vertical_resolution=1.0, + ) + yield domain + domain.delete() + + @pytest.fixture + def spatial_builder(self, spatial_domain): + """Fixture providing a builder using the small UTM domain.""" + return SurfaceGridBuilder(domain_id=spatial_domain.id) + + @pytest.fixture + def sample_polygon(self): + """Sample polygon geometry for testing (~100m x 100m area in UTM Zone 11N).""" + # Polygon within the spatial_domain bounds (255900-256100, 4324800-4325000) + return { + "type": "Polygon", + "coordinates": [ + [ + [255950, 4324850], + [255950, 4324950], + [256050, 4324950], + [256050, 4324850], + [255950, 4324850], + ] + ], + } + + @pytest.fixture + def sample_multipolygon(self): + """Sample multipolygon geometry for testing (two ~50m x 50m areas in UTM).""" + return { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [255950, 4324850], + [255950, 4324900], + [256000, 4324900], + [256000, 4324850], + [255950, 4324850], + ] + ], + [ + [ + [256000, 4324900], + [256000, 4324950], + [256050, 4324950], + [256050, 4324900], + [256000, 4324900], + ] + ], + ], + } + + @pytest.fixture + def utm_crs(self): + """UTM Zone 11N CRS.""" + return {"type": "name", "properties": {"name": "EPSG:32611"}} + + def test_spatial_modification_basic(self, spatial_builder, sample_polygon, utm_crs): + """Test adding a basic spatial modification.""" + result = spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + crs=utm_crs, + ) + + assert result is spatial_builder + assert "modifications" in spatial_builder.config + assert len(spatial_builder.config["modifications"]) == 1 + + mod = spatial_builder.config["modifications"][0] + assert len(mod["actions"]) == 1 + assert len(mod["conditions"]) == 1 + + # Verify spatial condition + condition = mod["conditions"][0] + assert condition["operator"] == "within" + assert condition["geometry"] == sample_polygon + + def test_spatial_modification_with_operator( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test spatial modification with different operators.""" + for operator in ["within", "outside", "intersects"]: + spatial_builder.clear() + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + operator=operator, + crs=utm_crs, + ) + + mod = spatial_builder.config["modifications"][0] + condition = mod["conditions"][0] + assert condition["operator"] == operator + + def test_spatial_modification_with_target( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test spatial modification with target specification.""" + for target in ["centroid", "cell"]: + spatial_builder.clear() + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + target=target, + crs=utm_crs, + ) + + mod = spatial_builder.config["modifications"][0] + condition = mod["conditions"][0] + assert condition["target"] == target + + def test_spatial_modification_with_crs(self, spatial_builder, sample_polygon): + """Test spatial modification with custom CRS.""" + crs = {"type": "name", "properties": {"name": "EPSG:32611"}} + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + crs=crs, + ) + + mod = spatial_builder.config["modifications"][0] + condition = mod["conditions"][0] + assert condition["crs"] == crs + + def test_spatial_modification_with_additional_conditions( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test spatial modification with additional attribute conditions.""" + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + crs=utm_crs, + additional_conditions={ + "attribute": "FBFM", + "operator": "eq", + "value": "GR2", + }, + ) + + mod = spatial_builder.config["modifications"][0] + assert len(mod["conditions"]) == 2 + + # First condition should be spatial + spatial_condition = mod["conditions"][0] + assert spatial_condition["operator"] == "within" + assert spatial_condition["geometry"] == sample_polygon + + # Second condition should be attribute-based + attr_condition = mod["conditions"][1] + assert attr_condition["attribute"] == "FBFM" + assert attr_condition["operator"] == "eq" + assert attr_condition["value"] == "GR2" + + def test_spatial_modification_with_multiple_additional_conditions( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test spatial modification with multiple additional conditions.""" + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + crs=utm_crs, + additional_conditions=[ + {"attribute": "FBFM", "operator": "eq", "value": "GR2"}, + {"attribute": "fuelLoad", "operator": "gt", "value": 0.3}, + ], + ) + + mod = spatial_builder.config["modifications"][0] + assert len(mod["conditions"]) == 3 # 1 spatial + 2 attribute conditions + + def test_spatial_modification_with_multipolygon( + self, spatial_builder, sample_multipolygon, utm_crs + ): + """Test spatial modification with MultiPolygon geometry.""" + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_multipolygon, + crs=utm_crs, + ) + + mod = spatial_builder.config["modifications"][0] + condition = mod["conditions"][0] + assert condition["geometry"]["type"] == "MultiPolygon" + + def test_spatial_modification_with_multiple_actions( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test spatial modification with multiple actions.""" + spatial_builder.with_spatial_modification( + actions=[ + {"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + {"attribute": "fuelMoisture", "modifier": "multiply", "value": 1.2}, + ], + geometry=sample_polygon, + crs=utm_crs, + ) + + mod = spatial_builder.config["modifications"][0] + assert len(mod["actions"]) == 2 + + def test_multiple_spatial_modifications( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test adding multiple spatial modifications.""" + # First spatial modification - fuel break + spatial_builder.with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + operator="within", + crs=utm_crs, + ) + + # Second spatial modification - different area within domain bounds + other_polygon = { + "type": "Polygon", + "coordinates": [ + [ + [255950, 4324950], + [255950, 4325000], + [256000, 4325000], + [256000, 4324950], + [255950, 4324950], + ] + ], + } + spatial_builder.with_spatial_modification( + actions={"attribute": "FBFM", "modifier": "replace", "value": "NB1"}, + geometry=other_polygon, + operator="within", + crs=utm_crs, + ) + + assert len(spatial_builder.config["modifications"]) == 2 + + def test_spatial_modification_chaining( + self, spatial_builder, sample_polygon, utm_crs + ): + """Test that spatial modifications can be chained with other builder methods.""" + result = ( + spatial_builder.with_uniform_fuel_load(0.5) + .with_spatial_modification( + actions={"attribute": "fuelLoad", "modifier": "multiply", "value": 0.5}, + geometry=sample_polygon, + crs=utm_crs, + ) + .with_uniform_fuel_moisture(15.0) + ) + + assert result is spatial_builder + assert "fuel_load" in spatial_builder.config + assert "fuel_moisture" in spatial_builder.config + assert "modifications" in spatial_builder.config + + class TestFCCSSerialization: """Test suite for FCCS serialization bug fix.