From 77fb6b5a2c6f32079b0fe3bd1b4e555f2e9f387e Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 10:35:57 -0600 Subject: [PATCH] Add Fosberg dead fuel moisture grid SDK support Wrap the create_fosberg_fuel_moisture_grid endpoint as create_dead_fuel_moisture_grid_from_fosberg, a new fuel_moisture grid family derived from a topography grid (slope + aspect) and a Leaflux irradiance grid. Source grids are accepted as Grid objects or bare ids; when a Grid is passed it is checked for completion and required bands, and the domain is resolved from it. Validates dry_bulb_temp (>= 10), relative_humidity (0-100), and time (HHMM 0800-1959), and coerces month/elevation to their enums. Adds offline unit tests for request-building and range validation plus a live end-to-end test. --- fastfuels_sdk/v2/grids.py | 166 ++++++++++++++++++++++ tests/v2/test_grids.py | 284 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 450 insertions(+) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index 07dd0a9..0487628 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -21,6 +21,7 @@ create_compose_grid, create_duet_grid, create_fccs_lookup, + create_fosberg_fuel_moisture_grid, create_fbfm13_lookup, create_fbfm40_lookup, create_geotiff_upload, @@ -58,6 +59,7 @@ CreateFccsLookupRequest, CreateFbfm13LookupRequest, CreateFbfm40LookupRequest, + CreateFosbergFuelMoistureRequest, CreateGeoTIFFUploadRequest, CreateLandfireCanopyRequest, CreateLandfireFbfm13Request, @@ -79,6 +81,7 @@ FccsLookupBand, Fbfm13LookupBand, Fbfm40LookupBand, + FuelMoistureMonth, GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, @@ -97,6 +100,7 @@ ListGridsResponse, MetaCHMVersion, NonBurnableFuelModel, + RelativeElevation, ResamplingMethod, SortOrder, ThreeDepResolution, @@ -134,6 +138,7 @@ "create_grid_from_compose", "create_uniform_grid", "create_surface_fuel_grid_from_duet", + "create_dead_fuel_moisture_grid_from_fosberg", "create_fuel_grid_from_fccs_lookup", "create_fuel_grid_from_fbfm13_lookup", "create_fuel_grid_from_fbfm40_lookup", @@ -2190,6 +2195,167 @@ def _duet_integer(name: str, value, *, minimum: int, maximum: int) -> int: return value +def create_dead_fuel_moisture_grid_from_fosberg( + source_topography_grid, + source_irradiance_grid, + dry_bulb_temp: float, + relative_humidity: float, + time: int, + month, + elevation=None, + name: str = "", + description: str = "", + tags: Optional[List[str]] = None, +) -> Grid: + """Create a Fosberg 1-hour dead fuel moisture grid from two grids. + + Derives a grid with a single ``fuel_moisture.dead.1hr`` band (percent) + using the Fosberg & Deeming 1-hour dead fuel moisture model. The output + inherits the topography grid's domain, CRS, transform, and georeference. + + Parameters + ---------- + source_topography_grid : Grid or str + A completed 2D topography grid (or its id) carrying ``slope`` and + ``aspect`` bands, both in degrees. + source_irradiance_grid : Grid or str + A completed Leaflux irradiance grid (or its id) carrying an + ``irradiance.surface.relative`` band; per-cell shading is derived + from it. Must belong to the same domain as the topography grid. + dry_bulb_temp : float + Dry-bulb air temperature in degrees Fahrenheit. Must be >= 10. + relative_humidity : float + Relative humidity as a percent, from 0 through 100. + time : int + Local time of day in 24-hour HHMM form (e.g. 1200 for noon), from + 0800 through 1959. + month : FuelMoistureMonth or str + Month of the burn scenario, selecting the Fosberg correction table + (``FuelMoistureMonth`` member or a month name such as ``"July"``). + elevation : RelativeElevation or str, optional + Site elevation relative to the reference weather station + (``RelativeElevation`` member or one of ``"below"``, ``"near"``, + ``"above"``): ``below`` = 1000-2000 ft below the station, ``near`` = + within 1000 ft (no correction), ``above`` = 1000-2000 ft above. This + is a Fosberg correction category, not the topography elevation band. + name, description : str, optional + Metadata for the new grid. + tags : List[str], optional + Tags for the new grid. + + Returns + ------- + Grid + The new pending Fosberg dead fuel moisture Grid. + + Raises + ------ + TypeError + If ``dry_bulb_temp``, ``relative_humidity``, or ``time`` is not a + number, or ``time`` is not a whole number. + ValueError + If a parameter is out of range, a source grid passed as a Grid is not + completed or lacks a required band, or the domain cannot be resolved + because both source grids were passed as bare ids. + """ + # Local band/completion checks only apply when a Grid object is supplied; + # bare ids are validated server-side. The domain id is taken from whichever + # source grid is a Grid object. + topo_id, topo_domain = _resolve_fosberg_source_grid( + source_topography_grid, + required_bands={"slope", "aspect"}, + kind="topography", + action="derive a Fosberg dead fuel moisture grid from", + ) + irradiance_id, irradiance_domain = _resolve_fosberg_source_grid( + source_irradiance_grid, + required_bands={"irradiance.surface.relative"}, + kind="irradiance", + action="derive a Fosberg dead fuel moisture grid from", + ) + domain_id = topo_domain if topo_domain is not None else irradiance_domain + if domain_id is None: + raise ValueError( + "Cannot resolve the domain from grid ids alone. Pass at least one " + "source grid as a Grid object, or load them with " + "get_grid(domain, id) first." + ) + + dry_bulb_temp = _fosberg_number("dry_bulb_temp", dry_bulb_temp, minimum=10) + relative_humidity = _fosberg_number( + "relative_humidity", relative_humidity, minimum=0, maximum=100 + ) + time = _fosberg_time(time) + month = month if isinstance(month, FuelMoistureMonth) else FuelMoistureMonth(month) + if elevation is None: + elevation = UNSET + elif not isinstance(elevation, RelativeElevation): + elevation = RelativeElevation(elevation) + + request_body = CreateFosbergFuelMoistureRequest( + source_topography_grid_id=topo_id, + source_irradiance_grid_id=irradiance_id, + dry_bulb_temp=dry_bulb_temp, + relative_humidity=relative_humidity, + time=time, + month=month, + elevation=elevation, + name=name, + description=description, + tags=_opt(tags), + ) + response = create_fosberg_fuel_moisture_grid.sync_detailed( + domain_id, + client=ensure_client(), + body=request_body, + ) + return Grid._from_model(expect(response, HTTPStatus.CREATED)) + + +def _resolve_fosberg_source_grid(grid, *, required_bands, kind: str, action: str): + """Resolve a Fosberg source grid to ``(grid_id, domain_id_or_None)``. + + When ``grid`` is a Grid, require it be completed and carry every band in + ``required_bands`` and return its domain id. When it is a bare id string, + skip local validation (the server validates) and return ``None`` for the + domain id. + """ + if not isinstance(grid, Grid): + return grid, None + grid._require_completed(action) + band_keys = {band.key for band in grid.bands} + missing = sorted(set(required_bands) - band_keys) + if missing: + raise ValueError( + f"{kind} grid {grid.id} lacks required bands: {missing}. " + f"Available bands: {sorted(band_keys)}." + ) + return grid.id, grid.domain_id + + +def _fosberg_number(name: str, value, *, minimum=None, maximum=None): + """Validate a bounded Fosberg weather parameter.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a number.") + if minimum is not None and value < minimum: + raise ValueError(f"{name} must be >= {minimum}.") + if maximum is not None and value > maximum: + raise ValueError(f"{name} must be <= {maximum}.") + return value + + +def _fosberg_time(value) -> int: + """Validate a Fosberg local time of day in 24-hour HHMM form.""" + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("time must be a whole number in HHMM form.") + minute = value % 100 + if not 0 <= minute <= 59: + raise ValueError("time must be a valid HHMM clock time (minutes 00-59).") + if not 800 <= value <= 1959: + raise ValueError("time must be between 0800 and 1959 (HHMM).") + return value + + # --------------------------------------------------------------------------- # Listing, fetching, and utilities # --------------------------------------------------------------------------- diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index 7b1e01e..c09fddc 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -26,6 +26,7 @@ create_canopy_height_grid_from_meta, create_canopy_height_grid_from_naip_chm, create_canopy_height_grid_from_point_cloud, + create_dead_fuel_moisture_grid_from_fosberg, create_fuel_grid_from_fccs_lookup, create_fuel_grid_from_fbfm13_lookup, create_fuel_grid_from_fbfm40_lookup, @@ -50,6 +51,7 @@ DuetBand, FccsLookupBand, Fbfm13LookupBand, + FuelMoistureMonth, GridAlignmentDomainTarget, GridAlignmentGridTarget, GridAlignmentNativeTarget, @@ -63,6 +65,7 @@ Modifier, Operator, PointCloudType, + RelativeElevation, ResamplingMethod, TopographyBand, UploadBandDefinition, @@ -492,6 +495,287 @@ def test_create_live(self, completed_tree_inventory): voxels.delete() +class TestCreateDeadFuelMoistureGridFromFosberg: + @staticmethod + def _topo_grid(status=JobStatus.COMPLETED, omit=None): + bands = [ + ("slope", BandType.CONTINUOUS), + ("aspect", BandType.CONTINUOUS), + ] + return Grid( + id="topo-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=[ + Band(key=key, type_=type_, index=index) + for index, (key, type_) in enumerate(bands) + if key != omit + ], + ) + + @staticmethod + def _irradiance_grid(status=JobStatus.COMPLETED, omit=None): + bands = [("irradiance.surface.relative", BandType.CONTINUOUS)] + return Grid( + id="irradiance-grid-id", + domain_id="domain-id", + status=status, + source=GridSource(), + bands=[ + Band(key=key, type_=type_, index=index) + for index, (key, type_) in enumerate(bands) + if key != omit + ], + ) + + def _patch_endpoint(self, monkeypatch): + created = Grid( + id="fosberg-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_fosberg_fuel_moisture_grid, + "sync_detailed", + fake_create, + ) + return captured, client + + def test_builds_request_from_completed_grids(self, monkeypatch): + captured, client = self._patch_endpoint(monkeypatch) + + grid = create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(), + self._irradiance_grid(), + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + elevation="near", + name="Fosberg moisture", + tags=["test"], + ) + + assert grid.id == "fosberg-grid-id" + assert captured["domain_id"] == "domain-id" + assert captured["client"] is client + body = captured["body"] + assert body.source_topography_grid_id == "topo-grid-id" + assert body.source_irradiance_grid_id == "irradiance-grid-id" + assert body.dry_bulb_temp == 75.0 + assert body.relative_humidity == 20.0 + assert body.time == 1200 + assert body.month == FuelMoistureMonth.JULY + assert body.elevation == RelativeElevation.NEAR + assert body.name == "Fosberg moisture" + assert body.tags == ["test"] + + def test_elevation_defaults_to_unset(self, monkeypatch): + captured, _ = self._patch_endpoint(monkeypatch) + + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(), + self._irradiance_grid(), + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month=FuelMoistureMonth.AUGUST, + ) + + assert captured["body"].elevation is UNSET + + def test_accepts_grid_ids_with_one_object(self, monkeypatch): + # Irradiance passed as a bare id; the domain is resolved from the + # topography Grid object. + captured, _ = self._patch_endpoint(monkeypatch) + + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(), + "some-irradiance-id", + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + + assert captured["domain_id"] == "domain-id" + assert captured["body"].source_irradiance_grid_id == "some-irradiance-id" + + def test_requires_domain_when_both_ids(self, monkeypatch): + self._patch_endpoint(monkeypatch) + with pytest.raises(ValueError, match="resolve the domain"): + create_dead_fuel_moisture_grid_from_fosberg( + "topo-id", + "irradiance-id", + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + + def test_requires_completed_topography_grid(self): + with pytest.raises(ValueError, match=r"Call \.wait\(\)"): + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(status=JobStatus.PENDING), + self._irradiance_grid(), + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + + def test_requires_completed_irradiance_grid(self): + with pytest.raises(ValueError, match=r"Call \.wait\(\)"): + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(), + self._irradiance_grid(status=JobStatus.PENDING), + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + + def test_requires_topography_bands(self): + with pytest.raises(ValueError, match="aspect"): + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(omit="aspect"), + self._irradiance_grid(), + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + + def test_requires_irradiance_band(self): + with pytest.raises(ValueError, match="irradiance.surface.relative"): + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(), + self._irradiance_grid(omit="irradiance.surface.relative"), + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + + @pytest.mark.parametrize( + "kwargs,error", + [ + ({"dry_bulb_temp": 9}, ValueError), + ({"dry_bulb_temp": "hot"}, TypeError), + ({"relative_humidity": -1}, ValueError), + ({"relative_humidity": 101}, ValueError), + ({"time": 759}, ValueError), + ({"time": 2000}, ValueError), + ({"time": 1275}, ValueError), + ({"time": 12.0}, TypeError), + ], + ) + def test_validates_request_parameters(self, kwargs, error): + params = dict( + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + ) + params.update(kwargs) + with pytest.raises(error): + create_dead_fuel_moisture_grid_from_fosberg( + self._topo_grid(), + self._irradiance_grid(), + **params, + ) + + def test_create_live(self, completed_tree_inventory, completed_topography_grid): + import datetime + + from fastfuels_sdk.v2.client_library.api.grids import ( + create_leaflux_irradiance_grid, + ) + from fastfuels_sdk.v2.client_library.models import ( + CreateLeafluxIrradianceRequest, + LeafluxBand, + ) + from fastfuels_sdk.v2.exceptions import ApiException + + voxels = completed_tree_inventory.voxelize( + horizontal_resolution_m=2, + vertical_resolution_m=1, + bands=["leaf_area_density"], + name="test_fosberg_lad", + tags=["test"], + ) + irradiance = None + moisture = None + try: + voxels.wait() + # The Fosberg grid consumes a Leaflux irradiance grid, whose SDK + # surface is tracked separately. When that prerequisite cannot be + # built, skip rather than fail this Fosberg-focused test. + try: + leaflux_response = create_leaflux_irradiance_grid.sync_detailed( + voxels.domain_id, + client=ensure_client(), + body=CreateLeafluxIrradianceRequest( + source_grid_id=voxels.id, + date_time=datetime.datetime( + 2020, 7, 1, 18, 0, tzinfo=datetime.timezone.utc + ), + source_terrain_grid_id=completed_topography_grid.id, + bands=[LeafluxBand.IRRADIANCE_SURFACE_RELATIVE], + name="test_fosberg_irradiance", + tags=["test"], + ), + ) + irradiance = Grid._from_model( + expect(leaflux_response, HTTPStatus.CREATED) + ) + irradiance.wait() + except ApiException as exc: + pytest.skip(f"Leaflux irradiance grid unavailable: {exc}") + if irradiance.status != JobStatus.COMPLETED: + pytest.skip("Leaflux irradiance grid did not complete") + + moisture = create_dead_fuel_moisture_grid_from_fosberg( + completed_topography_grid, + irradiance, + dry_bulb_temp=75.0, + relative_humidity=20.0, + time=1200, + month="July", + elevation="near", + name="test_fosberg_moisture", + tags=["test"], + ) + moisture.wait() + assert moisture.status == JobStatus.COMPLETED + assert {band.key for band in moisture.bands} == {"fuel_moisture.dead.1hr"} + values = moisture.to_numpy("fuel_moisture.dead.1hr") + assert values.ndim == 2 + assert np.isfinite(values).any() + finally: + if moisture is not None: + moisture.delete() + if irradiance is not None: + irradiance.delete() + voxels.delete() + + class TestCreateFuelModelGridFromLandfireFbfm40: def test_create(self, test_domain): # remove_non_burnable exercises the string -> enum list coercion