From b2252e56cab53a8f3aca9dd4f35f5919d0cd6af0 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 10:32:56 -0600 Subject: [PATCH] Expose CHM aggregation and spike filter on point-cloud grids Add aggregation (max/mean/median/percentile with a percentile value) and spike_filter (bool, mapping, or ChmSpikeFilter) parameters to create_canopy_height_grid_from_point_cloud, translating them onto the generated union models via _build_chm_aggregation and _build_chm_spike_filter. Omitting them preserves existing behavior by sending UNSET. Extend the point-cloud grid tests with request-building coverage for a percentile aggregation and the spike filter, helper unit tests for both translators, and a live test that builds a CHM with a non-default aggregation and spike filter. --- fastfuels_sdk/v2/grids.py | 82 ++++++++++++++++- tests/v2/test_grids.py | 179 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 07dd0a9..864b49c 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -54,6 +54,11 @@ ComposeLiteral, ComposeSelect, CreateDuetRequest, + ChmMaxAggregation, + ChmMeanAggregation, + ChmMedianAggregation, + ChmPercentileAggregation, + ChmSpikeFilter, CreateComposeRequest, CreateFccsLookupRequest, CreateFbfm13LookupRequest, @@ -205,6 +210,62 @@ def _build_alignment( return UNSET +def _build_chm_aggregation(aggregation, percentile): + """Translate an aggregation keyword into a CHM aggregation target. + + Returns a ``Chm{Max,Mean,Median,Percentile}Aggregation`` or ``UNSET`` (let + the API choose its default). + + - ``aggregation="max"`` / ``"mean"`` / ``"median"`` selects that statistic. + - ``aggregation="percentile"`` requires ``percentile`` (0-100). + - ``percentile`` is only valid with ``aggregation="percentile"``. + """ + if aggregation is None and percentile is None: + return UNSET + if aggregation == "percentile": + if percentile is None: + raise ValueError('aggregation="percentile" requires a percentile value.') + return ChmPercentileAggregation(percentile=percentile) + if percentile is not None: + raise ValueError('percentile is only used with aggregation="percentile".') + methods = { + "max": ChmMaxAggregation, + "mean": ChmMeanAggregation, + "median": ChmMedianAggregation, + } + if aggregation not in methods: + raise ValueError( + 'aggregation must be one of "max", "mean", "median", or ' + f'"percentile", got {aggregation!r}.' + ) + return methods[aggregation]() + + +def _build_chm_spike_filter(spike_filter): + """Translate a spike-filter keyword into a CHM spike-filter target. + + - ``None`` -> ``UNSET`` (the API applies its default filter). + - ``False`` -> ``None`` (disable filtering, keeping every return). + - ``True`` -> a default ``ChmSpikeFilter``. + - a mapping -> a ``ChmSpikeFilter`` built from its fields. + - a ``ChmSpikeFilter`` -> passed through unchanged. + """ + if spike_filter is None: + return UNSET + if spike_filter is False: + return None + if spike_filter is True: + return ChmSpikeFilter() + if isinstance(spike_filter, ChmSpikeFilter): + return spike_filter + if isinstance(spike_filter, Mapping): + return ChmSpikeFilter(**spike_filter) + raise ValueError( + "spike_filter must be a bool, a mapping of filter fields, or a " + f"ChmSpikeFilter, got {spike_filter!r}." + ) + + def _fill_for(dtype, nodata=None): """Pick the fill value for cells a chunk does not cover. @@ -1199,6 +1260,9 @@ def create_canopy_height_grid_from_point_cloud( output_resolution_m: Optional[float] = None, align_to=None, resampling: Optional[str] = None, + aggregation: Optional[str] = None, + percentile: Optional[float] = None, + spike_filter=None, extent_buffer_cells: int = 0, name: str = "", description: str = "", @@ -1218,6 +1282,19 @@ def create_canopy_height_grid_from_point_cloud( Match the lattice of an existing grid (or its id). resampling : str, optional Resampling method for the continuous canopy-height band. + aggregation : str, optional + Statistic each cell reduces its above-ground return heights with: one of + ``"max"``, ``"mean"``, ``"median"``, or ``"percentile"``. Defaults to the + API's ``"max"``. + percentile : float, optional + Rank to take (0-100), used only with ``aggregation="percentile"``. 100 is + the tallest return and 50 the median. + spike_filter : bool, Mapping, or ChmSpikeFilter, optional + Removal of lone spurious returns. ``None`` (the default) applies the + API's default filter; ``False`` disables it, keeping every return; + ``True`` applies a default ``ChmSpikeFilter``; a mapping of filter fields + (``min_canopy_footprint_m``, ``min_prominence_m``) or a ``ChmSpikeFilter`` + sets custom thresholds. extent_buffer_cells : int, optional Result-grid cells to buffer around the domain extent (0-10, default 0). name, description : str, optional @@ -1235,7 +1312,8 @@ def create_canopy_height_grid_from_point_cloud( Raises ------ ValueError - If the point cloud is not completed or is not airborne. + If the point cloud is not completed or is not airborne, or if the + aggregation and percentile arguments are inconsistent. """ if point_cloud.status != JobStatus.COMPLETED: raise ValueError( @@ -1255,6 +1333,8 @@ def create_canopy_height_grid_from_point_cloud( align_to, resampling=resampling, ), + aggregation=_build_chm_aggregation(aggregation, percentile), + spike_filter=_build_chm_spike_filter(spike_filter), extent_buffer_cells=extent_buffer_cells, name=name, description=description, diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 7b1e01e..40002f0 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -16,6 +16,8 @@ from fastfuels_sdk.v2.grids import ( Grid, _build_alignment, + _build_chm_aggregation, + _build_chm_spike_filter, _decode_grid_chunk, _domain_id, _enum_list, @@ -42,10 +44,20 @@ list_grids, ) from fastfuels_sdk.v2.api import ensure_client +from fastfuels_sdk.v2.domains import Domain +from fastfuels_sdk.v2.point_clouds import ( + create_point_cloud_from_3dep, + check_3dep_coverage as check_3dep_point_cloud_coverage, +) from fastfuels_sdk.v2.client_library.api.grids import get_grid_data_json from fastfuels_sdk.v2.client_library.models import ( Band, BandType, + ChmMaxAggregation, + ChmMeanAggregation, + ChmMedianAggregation, + ChmPercentileAggregation, + ChmSpikeFilter, ContinuousBandSummary, DuetBand, FccsLookupBand, @@ -261,6 +273,51 @@ def test_create(self, test_domain): grid.delete() +@pytest.fixture(scope="module") +def covered_3dep_point_cloud(): + """A completed ALS point cloud over a Bondurant, WY domain with stable + 3DEP LiDAR coverage. Owns its own domain so the live CHM test can build + grids inside it without touching the shared session domain. + """ + geojson = { + "type": "FeatureCollection", + "crs": {"type": "name", "properties": {"name": "EPSG:32612"}}, + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [522800, 4720400], + [523300, 4720400], + [523300, 4720900], + [522800, 4720900], + [522800, 4720400], + ] + ], + }, + } + ], + } + domain = Domain.from_geojson( + geojson, + name="test_point_cloud_chm_domain", + tags=["sdk-test"], + ) + coverage = check_3dep_point_cloud_coverage(domain) + point_cloud = create_point_cloud_from_3dep( + domain, + datasets=[coverage.datasets[0].name], + name="test_point_cloud_chm_source", + tags=["sdk-test"], + ) + point_cloud.wait() + yield point_cloud + domain.delete(force=True) + + class TestCreateCanopyHeightGridFromPointCloud: @staticmethod def _point_cloud(status=JobStatus.COMPLETED, type_=PointCloudType.ALS): @@ -324,6 +381,128 @@ def test_requires_airborne_cloud(self): self._point_cloud(type_=PointCloudType.TLS) ) + def _capture_body(self, monkeypatch, **kwargs): + created = Grid( + id="grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[Band(key="chm", type_=BandType.CONTINUOUS, index=0, unit="m")], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(body=body) + return Response( + status_code=HTTPStatus.CREATED, + content=b"", + headers={}, + parsed=created, + ) + + monkeypatch.setattr(grids, "ensure_client", lambda: object()) + monkeypatch.setattr(grids.create_point_cloud_chm, "sync_detailed", fake_create) + create_canopy_height_grid_from_point_cloud(self._point_cloud(), **kwargs) + return captured["body"] + + def test_defaults_leave_aggregation_and_spike_filter_unset(self, monkeypatch): + body = self._capture_body(monkeypatch) + assert body.aggregation is UNSET + assert body.spike_filter is UNSET + + def test_percentile_aggregation_in_request(self, monkeypatch): + body = self._capture_body(monkeypatch, aggregation="percentile", percentile=95) + assert isinstance(body.aggregation, ChmPercentileAggregation) + assert body.aggregation.percentile == 95 + + def test_spike_filter_thresholds_in_request(self, monkeypatch): + body = self._capture_body( + monkeypatch, + spike_filter={"min_canopy_footprint_m": 5, "min_prominence_m": 30}, + ) + assert isinstance(body.spike_filter, ChmSpikeFilter) + assert body.spike_filter.min_canopy_footprint_m == 5 + assert body.spike_filter.min_prominence_m == 30 + + def test_spike_filter_false_disables(self, monkeypatch): + body = self._capture_body(monkeypatch, spike_filter=False) + assert body.spike_filter is None + + def test_create_live(self, covered_3dep_point_cloud): + grid = None + try: + grid = create_canopy_height_grid_from_point_cloud( + covered_3dep_point_cloud, + output_resolution_m=2, + aggregation="percentile", + percentile=95, + spike_filter={"min_canopy_footprint_m": 5, "min_prominence_m": 30}, + name="test_point_cloud_chm", + tags=["sdk-test"], + ) + grid.wait() + assert grid.status == JobStatus.COMPLETED + assert {band.key for band in grid.bands} == {"chm"} + heights = grid.to_numpy("chm") + assert heights.ndim == 2 + assert np.isfinite(heights).any() + finally: + if grid is not None: + grid.delete() + + +class TestBuildChmAggregation: + def test_none_is_unset(self): + assert _build_chm_aggregation(None, None) is UNSET + + def test_max_mean_median(self): + assert isinstance(_build_chm_aggregation("max", None), ChmMaxAggregation) + assert isinstance(_build_chm_aggregation("mean", None), ChmMeanAggregation) + assert isinstance(_build_chm_aggregation("median", None), ChmMedianAggregation) + + def test_percentile_carries_value(self): + agg = _build_chm_aggregation("percentile", 90) + assert isinstance(agg, ChmPercentileAggregation) + assert agg.percentile == 90 + + def test_percentile_requires_value(self): + with pytest.raises(ValueError, match="requires a percentile"): + _build_chm_aggregation("percentile", None) + + def test_percentile_only_with_percentile_method(self): + with pytest.raises(ValueError, match="only used with"): + _build_chm_aggregation("max", 90) + with pytest.raises(ValueError, match="only used with"): + _build_chm_aggregation(None, 90) + + def test_unknown_method_raises(self): + with pytest.raises(ValueError, match="aggregation must be one of"): + _build_chm_aggregation("mode", None) + + +class TestBuildChmSpikeFilter: + def test_none_is_unset(self): + assert _build_chm_spike_filter(None) is UNSET + + def test_false_disables(self): + assert _build_chm_spike_filter(False) is None + + def test_true_is_default_filter(self): + assert isinstance(_build_chm_spike_filter(True), ChmSpikeFilter) + + def test_mapping_sets_fields(self): + sf = _build_chm_spike_filter({"min_prominence_m": 40}) + assert isinstance(sf, ChmSpikeFilter) + assert sf.min_prominence_m == 40 + + def test_instance_passes_through(self): + sf = ChmSpikeFilter(min_canopy_footprint_m=4) + assert _build_chm_spike_filter(sf) is sf + + def test_invalid_raises(self): + with pytest.raises(ValueError, match="spike_filter must be"): + _build_chm_spike_filter("aggressive") + class TestCreateSurfaceFuelGridFromDuet: @staticmethod