From ec3fbf5719e700c316f0621e15ee70a6efb9e2e7 Mon Sep 17 00:00:00 2001 From: amarcozzi Date: Wed, 26 Aug 2026 16:27:31 -0600 Subject: [PATCH] Add seasonal FBFM40 support (season argument) Expose a season argument on create_fuel_model_grid_from_landfire_fbfm40 so callers can request LANDFIRE Seasonal Fuels (ES/SP/SU/FA) from the LANDFIRE Product Service instead of the staged annual release. The seasonal path requires a seasonal vintage (e.g. 2025); the annual path is unchanged when season is omitted. The API validates the version/season coupling and surfaces UnprocessableEntity on a mismatch or when LFPS does not cover the domain. Tests: offline coverage that season and version are forwarded and that an unknown season raises; a live test that a seasonal request against a covered domain returns a pending grid whose represented_year is the projected season year (2025 + SP -> 2026). The live test reads represented_year off the pending grid rather than waiting, since grid completion submits a real on-demand LFPS job; it skips when LFPS is not serving the domain/season. --- fastfuels_sdk/v2/grids.py | 13 ++++- tests/v2/test_grids.py | 104 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/fastfuels_sdk/v2/grids.py b/fastfuels_sdk/v2/grids.py index c643215..e0d565a 100644 --- a/fastfuels_sdk/v2/grids.py +++ b/fastfuels_sdk/v2/grids.py @@ -131,6 +131,7 @@ LandfireFbfm13Version, LandfireFbfm40Version, LandfireFccsVersion, + LandfireSeason, LandfireTopographyVersion, LeafluxBand, ListGridsResponse, @@ -1774,6 +1775,7 @@ def create_fuel_model_grid_from_landfire_fbfm13( def create_fuel_model_grid_from_landfire_fbfm40( domain, version: Optional[str] = None, + season: Optional[str] = None, remove_non_burnable: Optional[list] = None, output_resolution_m: Optional[float] = None, align_to=None, @@ -1793,7 +1795,15 @@ def create_fuel_model_grid_from_landfire_fbfm40( The domain (or its id) to create the grid in. version : str, optional LANDFIRE version (see ``LandfireFbfm40Version``). Defaults to the API's - current version. + current version. Seasonal versions (see ``season``) are only available + for the LANDFIRE Seasonal Fuels vintages (e.g. "2025"). + season : str, optional + LANDFIRE Seasonal Fuels window (see ``LandfireSeason``): "ES" (early + spring), "SP" (spring), "SU" (summer), or "FA" (fall). When set, the + grid is fetched from the LANDFIRE Product Service for that season rather + than the staged annual release, and ``version`` must be a seasonal + vintage (e.g. "2025"). When omitted, ``version`` must be an annual + vintage. ``represented_year`` reflects the projected season year. remove_non_burnable : list, optional Non-burnable fuel models to drop (``NonBurnableFuelModel`` members or their string keys, e.g. "NB1", "NB2"). @@ -1821,6 +1831,7 @@ def create_fuel_model_grid_from_landfire_fbfm40( """ request_body = CreateLandfireFbfm40Request( version=LandfireFbfm40Version(version) if version is not None else UNSET, + season=LandfireSeason(season) if season is not None else UNSET, remove_non_burnable=_enum_list(remove_non_burnable, NonBurnableFuelModel), alignment=_build_alignment(output_resolution_m, align_to, align, resampling), extent_buffer_cells=extent_buffer_cells, diff --git a/tests/v2/test_grids.py b/tests/v2/test_grids.py index ccb439a..c18fc8b 100644 --- a/tests/v2/test_grids.py +++ b/tests/v2/test_grids.py @@ -92,6 +92,7 @@ from fastfuels_sdk.v2.client_library.types import UNSET, Response from fastfuels_sdk.v2.exceptions import ( NotFoundException, + UnprocessableEntityException, expect, ) from fastfuels_sdk.v2.modifications import mask @@ -1348,6 +1349,109 @@ def test_rejects_unknown_version(self): version="1999", ) + def test_season_passed_through(self, monkeypatch): + # A seasonal request forwards both the seasonal version and the + # season window to the request body. + created = Grid( + id="fbfm40-grid-id", + domain_id="domain-id", + status=JobStatus.PENDING, + source=GridSource(), + bands=[], + ) + captured = {} + + def fake_create(domain_id, *, client, body): + captured.update(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_landfire_fbfm40, "sync_detailed", fake_create) + + create_fuel_model_grid_from_landfire_fbfm40( + SimpleNamespace(id="domain-id"), + version="2025", + season="SP", + ) + + assert captured["body"].version.value == "2025" + assert captured["body"].season.value == "SP" + + def test_rejects_unknown_season(self): + with pytest.raises(ValueError): + create_fuel_model_grid_from_landfire_fbfm40( + SimpleNamespace(id="domain-id"), + version="2025", + season="WINTER", + ) + + def test_create_seasonal_reports_projected_year(self): + # LANDFIRE Seasonal Fuels (version 2025 + SP is spring 2026). The + # projected season year is resolved from the live LFPS catalog at + # request time and surfaces as represented_year on the pending grid + # -- so the wiring is confirmed without waiting for the grid to build + # (the build submits a real, up-to-20-minute on-demand LFPS job). + # + # Seasonal Fuels is a geographically limited pilot served live by the + # LANDFIRE Product Service, so this needs a domain inside the current + # spring coverage region (a small box in northern Arizona). Coverage is + # a moving boundary; if LFPS isn't serving this domain/season when the + # test runs, skip rather than fail -- the request wiring itself is + # covered by test_season_passed_through. + domain = Domain.from_geojson( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-110.276, 35.749], + [-110.252, 35.749], + [-110.252, 35.769], + [-110.276, 35.769], + [-110.276, 35.749], + ] + ], + }, + } + ], + }, + name="throwaway_seasonal_domain", + tags=["sdk-test"], + ) + try: + try: + grid = create_fuel_model_grid_from_landfire_fbfm40( + domain, + version="2025", + season="SP", + output_resolution_m=30, + name="throwaway_fbfm40_seasonal", + tags=["test"], + ) + except UnprocessableEntityException as exc: + pytest.skip( + f"LANDFIRE Seasonal Fuels not serving this domain/season: {exc}" + ) + + # Do NOT wait(): completion submits a live 20-minute LFPS job. + assert grid.status in (JobStatus.PENDING, JobStatus.RUNNING) + # version 2025 + SP projects to spring 2026, read from the catalog. + assert grid.represented_year == 2026 + grid.delete() + finally: + domain.delete(force=True) + class TestRepresentedYear: """Unit tests for the Grid.represented_year accessor (no API)."""