Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion fastfuels_sdk/v2/grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
ComposeLiteral,
ComposeSelect,
CreateDuetRequest,
ChmMaxAggregation,
ChmMeanAggregation,
ChmMedianAggregation,
ChmPercentileAggregation,
ChmSpikeFilter,
CreateComposeRequest,
CreateFccsLookupRequest,
CreateFbfm13LookupRequest,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 = "",
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand Down
179 changes: 179 additions & 0 deletions tests/v2/test_grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading